| author | |
| committer | |
| log | 4a4c6dd2adc350a68ba18bb28b24b5a7e4660dab |
| tree | 5bebaca08ccefa0603d9de20eb20c6cd939bb07e |
| parent | e816064945144957b6eb9b268912a98b0555644b |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: maria <maria@kuuro.net>38 files changed, 3006 insertions(+), 58 deletions(-)
.gitignore+2| ... | ... | @@ -30,6 +30,8 @@ dist-electron/ |
| 30 | 30 | .electron-runtime/ |
| 31 | 31 | .showcase/ |
| 32 | 32 | apps/mobile/.showcase/ |
| 33 | apps/mobile/.generated/ | |
| 34 | /.generated/ | |
| 33 | 35 | artifacts/app-store/screenshots/ |
| 34 | 36 | .github/pr-assets/ |
| 35 | 37 | native/**/target/ |
apps/mobile/metro.config.js+52-5| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | const fs = require("node:fs"); |
| 2 | 2 | const path = require("node:path"); |
| 3 | const { pathToFileURL } = require("node:url"); | |
| 3 | 4 | const { getDefaultConfig } = require("expo/metro-config"); |
| 4 | 5 | const { withUniwindConfig } = require("uniwind/metro"); |
| 5 | 6 | const extraThemes = require("./generated-uniwind-theme-names.json"); |
| ... | ... | @@ -7,6 +8,13 @@ const extraThemes = require("./generated-uniwind-theme-names.json"); |
| 7 | 8 | /** @type {import("expo/metro-config").MetroConfig} */ |
| 8 | 9 | const config = getDefaultConfig(__dirname); |
| 9 | 10 | const workspaceRoot = path.resolve(__dirname, "../.."); |
| 11 | const generatedLicenseModuleRoot = path.join(__dirname, ".generated", "third-party-licenses"); | |
| 12 | const licenseGeneratorSource = path.join( | |
| 13 | workspaceRoot, | |
| 14 | "scripts", | |
| 15 | "lib", | |
| 16 | "third-party-licenses.ts", | |
| 17 | ); | |
| 10 | 18 | const escapedWorkspaceRoot = workspaceRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); |
| 11 | 19 | const mobileShikiRoot = path.dirname(require.resolve("shiki/package.json", { paths: [__dirname] })); |
| 12 | 20 | const resolveShikiDependencyRoot = (packageName) => { |
| ... | ... | @@ -37,6 +45,7 @@ config.resolver = { |
| 37 | 45 | ], |
| 38 | 46 | extraNodeModules: { |
| 39 | 47 | ...config.resolver?.extraNodeModules, |
| 48 | "@t3tools/mobile-third-party-licenses": generatedLicenseModuleRoot, | |
| 40 | 49 | shiki: mobileShikiRoot, |
| 41 | 50 | "@shikijs/core": resolveShikiDependencyRoot("@shikijs/core"), |
| 42 | 51 | "@shikijs/engine-javascript": resolveShikiDependencyRoot("@shikijs/engine-javascript"), |
| ... | ... | @@ -48,8 +57,46 @@ config.resolver = { |
| 48 | 57 | }, |
| 49 | 58 | }; |
| 50 | 59 | |
| 51 | module.exports = withUniwindConfig(config, { | |
| 52 | cssEntryFile: "./global.css", | |
| 53 | extraThemes, | |
| 54 | polyfills: { rem: 14 }, | |
| 55 | }); | |
| 60 | async function writeFileIfChanged(filePath, contents) { | |
| 61 | try { | |
| 62 | if ((await fs.promises.readFile(filePath, "utf8")) === contents) return; | |
| 63 | } catch (error) { | |
| 64 | if (error?.code !== "ENOENT") throw error; | |
| 65 | } | |
| 66 | await fs.promises.writeFile(filePath, contents, "utf8"); | |
| 67 | } | |
| 68 | ||
| 69 | async function generateMobileThirdPartyLicenses() { | |
| 70 | await fs.promises.mkdir(generatedLicenseModuleRoot, { recursive: true }); | |
| 71 | const generatorVersion = (await fs.promises.stat(licenseGeneratorSource)).mtimeMs; | |
| 72 | const { generateThirdPartyLicenseManifest } = await import( | |
| 73 | `${pathToFileURL(licenseGeneratorSource).href}?version=${String(generatorVersion)}` | |
| 74 | ); | |
| 75 | const manifest = await generateThirdPartyLicenseManifest({ | |
| 76 | configFile: path.join(workspaceRoot, "third-party-licenses.config.json"), | |
| 77 | packageManifests: [{ bundle: "mobile", path: path.join(__dirname, "package.json") }], | |
| 78 | allowMissingGeneratedNotices: | |
| 79 | process.env.NODE_ENV !== "production" && | |
| 80 | process.env.EAS_BUILD !== "true" && | |
| 81 | process.env.T3CODE_LICENSES_STRICT !== "1", | |
| 82 | }); | |
| 83 | ||
| 84 | await Promise.all([ | |
| 85 | writeFileIfChanged( | |
| 86 | path.join(generatedLicenseModuleRoot, "index.js"), | |
| 87 | `module.exports = ${JSON.stringify(manifest)};\n`, | |
| 88 | ), | |
| 89 | writeFileIfChanged( | |
| 90 | path.join(generatedLicenseModuleRoot, "package.json"), | |
| 91 | '{"main":"index.js"}\n', | |
| 92 | ), | |
| 93 | ]); | |
| 94 | } | |
| 95 | ||
| 96 | module.exports = generateMobileThirdPartyLicenses().then(() => | |
| 97 | withUniwindConfig(config, { | |
| 98 | cssEntryFile: "./global.css", | |
| 99 | extraThemes, | |
| 100 | polyfills: { rem: 14 }, | |
| 101 | }), | |
| 102 | ); |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_package.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_package.png and /dev/null differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_readme.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_readme.png and /dev/null differ |
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tsconfig.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tsconfig.png and /dev/null differ |
apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs-4| ... | ... | @@ -79,11 +79,7 @@ const colors = { |
| 79 | 79 | |
| 80 | 80 | const customIcons = { |
| 81 | 81 | agents: "t3-file-icon-agents", |
| 82 | claude: "t3-file-icon-claude", | |
| 83 | package: "t3-file-icon-package-json", | |
| 84 | 82 | pnpm: "t3-file-icon-pnpm", |
| 85 | readme: "t3-file-icon-readme", | |
| 86 | tsconfig: "t3-file-icon-tsconfig", | |
| 87 | 83 | video: "t3-file-icon-video", |
| 88 | 84 | }; |
| 89 | 85 |
apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts-3| ... | ... | @@ -30,13 +30,11 @@ export const MARKDOWN_FILE_ICON_SOURCES = { |
| 30 | 30 | nextjs: require("../assets/file-icons/pierre_nextjs.png"), |
| 31 | 31 | npm: require("../assets/file-icons/pierre_npm.png"), |
| 32 | 32 | oxc: require("../assets/file-icons/pierre_oxc.png"), |
| 33 | package: require("../assets/file-icons/pierre_package.png"), | |
| 34 | 33 | pnpm: require("../assets/file-icons/pierre_pnpm.png"), |
| 35 | 34 | postcss: require("../assets/file-icons/pierre_postcss.png"), |
| 36 | 35 | prettier: require("../assets/file-icons/pierre_prettier.png"), |
| 37 | 36 | python: require("../assets/file-icons/pierre_python.png"), |
| 38 | 37 | react: require("../assets/file-icons/pierre_react.png"), |
| 39 | readme: require("../assets/file-icons/pierre_readme.png"), | |
| 40 | 38 | ruby: require("../assets/file-icons/pierre_ruby.png"), |
| 41 | 39 | rust: require("../assets/file-icons/pierre_rust.png"), |
| 42 | 40 | sass: require("../assets/file-icons/pierre_sass.png"), |
| ... | ... | @@ -49,7 +47,6 @@ export const MARKDOWN_FILE_ICON_SOURCES = { |
| 49 | 47 | tailwind: require("../assets/file-icons/pierre_tailwind.png"), |
| 50 | 48 | terraform: require("../assets/file-icons/pierre_terraform.png"), |
| 51 | 49 | text: require("../assets/file-icons/pierre_text.png"), |
| 52 | tsconfig: require("../assets/file-icons/pierre_tsconfig.png"), | |
| 53 | 50 | typescript: require("../assets/file-icons/pierre_typescript.png"), |
| 54 | 51 | video: require("../assets/file-icons/pierre_video.png"), |
| 55 | 52 | vite: require("../assets/file-icons/pierre_vite.png"), |
apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts+4-4| ... | ... | @@ -116,7 +116,7 @@ const FILE_ICON_BY_NAME: Readonly<Record<string, MarkdownFileIcon>> = { |
| 116 | 116 | "next.config.mjs": "nextjs", |
| 117 | 117 | "next.config.mts": "nextjs", |
| 118 | 118 | "next.config.ts": "nextjs", |
| 119 | "package.json": "package", | |
| 119 | "package.json": "npm", | |
| 120 | 120 | "pnpm-lock.yaml": "pnpm", |
| 121 | 121 | "pnpm-workspace.yaml": "pnpm", |
| 122 | 122 | "postcss.config.js": "postcss", |
| ... | ... | @@ -127,7 +127,7 @@ const FILE_ICON_BY_NAME: Readonly<Record<string, MarkdownFileIcon>> = { |
| 127 | 127 | "prettier.config.cjs": "prettier", |
| 128 | 128 | "prettier.config.mjs": "prettier", |
| 129 | 129 | rakefile: "ruby", |
| 130 | "readme.md": "readme", | |
| 130 | "readme.md": "markdown", | |
| 131 | 131 | "stylelint.config.js": "stylelint", |
| 132 | 132 | "stylelint.config.cjs": "stylelint", |
| 133 | 133 | "stylelint.config.mjs": "stylelint", |
| ... | ... | @@ -139,7 +139,7 @@ const FILE_ICON_BY_NAME: Readonly<Record<string, MarkdownFileIcon>> = { |
| 139 | 139 | "tailwind.config.cjs": "tailwind", |
| 140 | 140 | "tailwind.config.mjs": "tailwind", |
| 141 | 141 | "tailwind.config.ts": "tailwind", |
| 142 | "tsconfig.json": "tsconfig", | |
| 142 | "tsconfig.json": "typescript", | |
| 143 | 143 | "vite.config.js": "vite", |
| 144 | 144 | "vite.config.mjs": "vite", |
| 145 | 145 | "vite.config.mts": "vite", |
| ... | ... | @@ -253,7 +253,7 @@ export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { |
| 253 | 253 | const exactIcon = FILE_ICON_BY_NAME[basename]; |
| 254 | 254 | if (exactIcon) return exactIcon; |
| 255 | 255 | if (basename.startsWith("tsconfig.") && basename.endsWith(".json")) { |
| 256 | return "tsconfig"; | |
| 256 | return "typescript"; | |
| 257 | 257 | } |
| 258 | 258 | const segments = basename.split("."); |
| 259 | 259 | for (let index = 1; index < segments.length; index += 1) { |
apps/mobile/src/Stack.tsx+18| ... | ... | @@ -55,6 +55,10 @@ import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsCl |
| 55 | 55 | import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; |
| 56 | 56 | import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; |
| 57 | 57 | import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; |
| 58 | import { | |
| 59 | SettingsOpenSourceLicenseRouteScreen, | |
| 60 | SettingsOpenSourceLicensesRouteScreen, | |
| 61 | } from "./features/settings/SettingsOpenSourceLicensesRouteScreen"; | |
| 58 | 62 | import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen"; |
| 59 | 63 | import { UsageLimitAccountScreen } from "./features/usage/UsageLimitsPooled"; |
| 60 | 64 | import { UsageRouteScreen } from "./features/usage/UsageRouteScreen"; |
| ... | ... | @@ -197,6 +201,20 @@ const SettingsContentStack = createNativeStackNavigator({ |
| 197 | 201 | screen: UsageLimitAccountScreen, |
| 198 | 202 | options: { title: "Account" }, |
| 199 | 203 | }), |
| 204 | SettingsOpenSourceLicenses: createNativeStackScreen({ | |
| 205 | screen: SettingsOpenSourceLicensesRouteScreen, | |
| 206 | linking: "open-source-licenses", | |
| 207 | options: { | |
| 208 | title: "Open source licenses", | |
| 209 | }, | |
| 210 | }), | |
| 211 | SettingsOpenSourceLicense: createNativeStackScreen({ | |
| 212 | screen: SettingsOpenSourceLicenseRouteScreen, | |
| 213 | linking: "open-source-licenses/:entryKey", | |
| 214 | options: { | |
| 215 | title: "License notice", | |
| 216 | }, | |
| 217 | }), | |
| 200 | 218 | SettingsUsage: createNativeStackScreen({ |
| 201 | 219 | screen: UsageRouteScreen, |
| 202 | 220 | linking: "usage", |
apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx created+241| ... | ... | @@ -0,0 +1,241 @@ |
| 1 | import { LegendList } from "@legendapp/list/react-native"; | |
| 2 | import { type StaticScreenProps, useNavigation } from "@react-navigation/native"; | |
| 3 | import { | |
| 4 | filterThirdPartyLicenseEntries, | |
| 5 | findThirdPartyLicenseEntry, | |
| 6 | formatLicenseBundles, | |
| 7 | thirdPartyLicenseEntryKey, | |
| 8 | type ThirdPartyLicenseEntry, | |
| 9 | } from "@t3tools/shared/thirdPartyLicenses"; | |
| 10 | import { useCallback, useMemo, useState } from "react"; | |
| 11 | import { Linking, Platform, Pressable, ScrollView, View } from "react-native"; | |
| 12 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 13 | ||
| 14 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | |
| 15 | import { SymbolView } from "../../components/AppSymbol"; | |
| 16 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | |
| 17 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | |
| 18 | import { getMobileThirdPartyLicenses } from "./mobileThirdPartyLicenses"; | |
| 19 | ||
| 20 | function useMobileThirdPartyLicenses() { | |
| 21 | return useMemo(() => { | |
| 22 | try { | |
| 23 | return getMobileThirdPartyLicenses(); | |
| 24 | } catch { | |
| 25 | return null; | |
| 26 | } | |
| 27 | }, []); | |
| 28 | } | |
| 29 | ||
| 30 | function LicenseRow(props: { | |
| 31 | readonly entry: ThirdPartyLicenseEntry; | |
| 32 | readonly onPress: () => void; | |
| 33 | }) { | |
| 34 | return ( | |
| 35 | <Pressable | |
| 36 | accessibilityHint="Opens the complete license notice" | |
| 37 | accessibilityLabel={`${props.entry.name}, ${props.entry.license}`} | |
| 38 | accessibilityRole="button" | |
| 39 | onPress={props.onPress} | |
| 40 | className="border-b border-border bg-card px-5 py-4 active:bg-card-alt" | |
| 41 | > | |
| 42 | <View className="flex-row items-start gap-3"> | |
| 43 | <View className="min-w-0 flex-1 gap-1"> | |
| 44 | <Text className="text-base font-t3-medium text-foreground" numberOfLines={2}> | |
| 45 | {props.entry.name} | |
| 46 | </Text> | |
| 47 | <Text className="text-sm text-foreground-muted" numberOfLines={2}> | |
| 48 | {props.entry.version ? `${props.entry.version} · ` : ""} | |
| 49 | {props.entry.license} | |
| 50 | </Text> | |
| 51 | </View> | |
| 52 | <SymbolView | |
| 53 | name="chevron.right" | |
| 54 | size={16} | |
| 55 | tintColorClassName={"accent-chevron"} | |
| 56 | type="monochrome" | |
| 57 | weight="semibold" | |
| 58 | /> | |
| 59 | </View> | |
| 60 | </Pressable> | |
| 61 | ); | |
| 62 | } | |
| 63 | ||
| 64 | export function SettingsOpenSourceLicensesRouteScreen() { | |
| 65 | const navigation = useNavigation(); | |
| 66 | const insets = useSafeAreaInsets(); | |
| 67 | const [query, setQuery] = useState(""); | |
| 68 | const manifest = useMobileThirdPartyLicenses(); | |
| 69 | const entries = manifest?.entries ?? []; | |
| 70 | const filteredEntries = useMemo( | |
| 71 | () => filterThirdPartyLicenseEntries(entries, query), | |
| 72 | [entries, query], | |
| 73 | ); | |
| 74 | const renderItem = useCallback( | |
| 75 | ({ item }: { readonly item: ThirdPartyLicenseEntry }) => ( | |
| 76 | <LicenseRow | |
| 77 | entry={item} | |
| 78 | onPress={() => | |
| 79 | navigation.navigate("SettingsSheet", { | |
| 80 | screen: "SettingsContent", | |
| 81 | params: { | |
| 82 | screen: "SettingsOpenSourceLicense", | |
| 83 | params: { entryKey: thirdPartyLicenseEntryKey(item) }, | |
| 84 | }, | |
| 85 | }) | |
| 86 | } | |
| 87 | /> | |
| 88 | ), | |
| 89 | [navigation], | |
| 90 | ); | |
| 91 | ||
| 92 | if (!manifest) { | |
| 93 | return ( | |
| 94 | <View collapsable={false} className="flex-1 bg-sheet"> | |
| 95 | {Platform.OS === "android" ? ( | |
| 96 | <> | |
| 97 | <NativeStackScreenOptions options={{ headerShown: false }} /> | |
| 98 | <AndroidScreenHeader title="Open source licenses" onBack={() => navigation.goBack()} /> | |
| 99 | </> | |
| 100 | ) : null} | |
| 101 | <View className="flex-1 items-center justify-center px-6"> | |
| 102 | <Text className="text-center text-base text-foreground-muted"> | |
| 103 | License notices are unavailable in this build. | |
| 104 | </Text> | |
| 105 | </View> | |
| 106 | </View> | |
| 107 | ); | |
| 108 | } | |
| 109 | ||
| 110 | return ( | |
| 111 | <View collapsable={false} className="flex-1 bg-sheet"> | |
| 112 | {Platform.OS === "android" ? ( | |
| 113 | <> | |
| 114 | <NativeStackScreenOptions options={{ headerShown: false }} /> | |
| 115 | <AndroidScreenHeader title="Open source licenses" onBack={() => navigation.goBack()} /> | |
| 116 | </> | |
| 117 | ) : null} | |
| 118 | <LegendList | |
| 119 | className="flex-1" | |
| 120 | contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} | |
| 121 | contentInsetAdjustmentBehavior="automatic" | |
| 122 | data={filteredEntries} | |
| 123 | estimatedItemSize={78} | |
| 124 | keyboardDismissMode="on-drag" | |
| 125 | keyboardShouldPersistTaps="handled" | |
| 126 | keyExtractor={thirdPartyLicenseEntryKey} | |
| 127 | ListEmptyComponent={ | |
| 128 | <View className="items-center px-6 py-12"> | |
| 129 | <Text className="text-center text-base text-foreground-muted"> | |
| 130 | No licenses match that search. | |
| 131 | </Text> | |
| 132 | </View> | |
| 133 | } | |
| 134 | ListHeaderComponent={ | |
| 135 | <View className="gap-4 px-5 pt-4 pb-5"> | |
| 136 | <Text className="text-base leading-normal text-foreground-muted"> | |
| 137 | Notices for dependencies, assets, and optional tools used by T3 Code Mobile. | |
| 138 | </Text> | |
| 139 | <TextInput | |
| 140 | accessibilityLabel="Search open-source licenses" | |
| 141 | autoCapitalize="none" | |
| 142 | autoCorrect={false} | |
| 143 | clearButtonMode="while-editing" | |
| 144 | onChangeText={setQuery} | |
| 145 | placeholder="Search packages" | |
| 146 | returnKeyType="search" | |
| 147 | value={query} | |
| 148 | /> | |
| 149 | <Text className="tabular-nums text-sm text-foreground-muted"> | |
| 150 | {filteredEntries.length === entries.length | |
| 151 | ? `${String(entries.length)} notices` | |
| 152 | : `${String(filteredEntries.length)} of ${String(entries.length)} notices`} | |
| 153 | </Text> | |
| 154 | </View> | |
| 155 | } | |
| 156 | renderItem={renderItem} | |
| 157 | showsVerticalScrollIndicator={false} | |
| 158 | /> | |
| 159 | </View> | |
| 160 | ); | |
| 161 | } | |
| 162 | ||
| 163 | type LicenseDetailProps = StaticScreenProps<{ readonly entryKey: string }>; | |
| 164 | ||
| 165 | export function SettingsOpenSourceLicenseRouteScreen({ route }: LicenseDetailProps) { | |
| 166 | const navigation = useNavigation(); | |
| 167 | const insets = useSafeAreaInsets(); | |
| 168 | const manifest = useMobileThirdPartyLicenses(); | |
| 169 | const entry = manifest | |
| 170 | ? findThirdPartyLicenseEntry(manifest.entries, route.params.entryKey) | |
| 171 | : undefined; | |
| 172 | const sourceUrl = entry?.sourceUrl?.match(/^https?:\/\//) ? entry.sourceUrl : null; | |
| 173 | ||
| 174 | if (!entry) { | |
| 175 | return ( | |
| 176 | <View collapsable={false} className="flex-1 bg-sheet"> | |
| 177 | {Platform.OS === "android" ? ( | |
| 178 | <> | |
| 179 | <NativeStackScreenOptions options={{ headerShown: false }} /> | |
| 180 | <AndroidScreenHeader title="License notice" onBack={() => navigation.goBack()} /> | |
| 181 | </> | |
| 182 | ) : null} | |
| 183 | <View className="flex-1 items-center justify-center px-6"> | |
| 184 | <Text className="text-center text-base text-foreground-muted"> | |
| 185 | This license notice is unavailable. | |
| 186 | </Text> | |
| 187 | </View> | |
| 188 | </View> | |
| 189 | ); | |
| 190 | } | |
| 191 | ||
| 192 | return ( | |
| 193 | <View collapsable={false} className="flex-1 bg-sheet"> | |
| 194 | {Platform.OS === "android" ? ( | |
| 195 | <> | |
| 196 | <NativeStackScreenOptions options={{ headerShown: false }} /> | |
| 197 | <AndroidScreenHeader title="License notice" onBack={() => navigation.goBack()} /> | |
| 198 | </> | |
| 199 | ) : null} | |
| 200 | <ScrollView | |
| 201 | className="flex-1" | |
| 202 | contentInsetAdjustmentBehavior="automatic" | |
| 203 | contentContainerClassName="gap-5 px-5 pt-4" | |
| 204 | contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} | |
| 205 | showsVerticalScrollIndicator={false} | |
| 206 | > | |
| 207 | <View className="gap-2 px-1"> | |
| 208 | <Text className="text-2xl font-t3-bold text-foreground">{entry.name}</Text> | |
| 209 | <Text className="text-base leading-normal text-foreground-muted"> | |
| 210 | {[entry.version, entry.license, formatLicenseBundles(entry.bundles)] | |
| 211 | .filter((value): value is string => Boolean(value)) | |
| 212 | .join(" · ")} | |
| 213 | </Text> | |
| 214 | {sourceUrl ? ( | |
| 215 | <Pressable | |
| 216 | accessibilityHint="Opens the project website" | |
| 217 | accessibilityRole="link" | |
| 218 | onPress={() => void Linking.openURL(sourceUrl)} | |
| 219 | className="min-h-12 flex-row items-center gap-2 self-start py-2 active:opacity-60" | |
| 220 | > | |
| 221 | <Text className="font-t3-medium text-primary">Project source</Text> | |
| 222 | <SymbolView | |
| 223 | name="arrow.up.right" | |
| 224 | size={16} | |
| 225 | tintColorClassName={"accent-primary"} | |
| 226 | type="monochrome" | |
| 227 | weight="semibold" | |
| 228 | /> | |
| 229 | </Pressable> | |
| 230 | ) : null} | |
| 231 | </View> | |
| 232 | ||
| 233 | <View className="overflow-hidden rounded-[24px] border-continuous bg-card p-4"> | |
| 234 | <Text selectable className="font-mono text-base leading-normal text-foreground"> | |
| 235 | {entry.noticeText} | |
| 236 | </Text> | |
| 237 | </View> | |
| 238 | </ScrollView> | |
| 239 | </View> | |
| 240 | ); | |
| 241 | } |
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+5| ... | ... | @@ -821,6 +821,11 @@ function AppSettingsSection() { |
| 821 | 821 | return ( |
| 822 | 822 | <SettingsSection title="App"> |
| 823 | 823 | <SettingsRow icon="internaldrive" label="Client Storage" target="SettingsClientStorage" /> |
| 824 | <SettingsRow | |
| 825 | icon="doc.on.doc" | |
| 826 | label="Open source licenses" | |
| 827 | target="SettingsOpenSourceLicenses" | |
| 828 | /> | |
| 824 | 829 | <SettingsRow icon="doc.text" label="Legal" fullScreenTarget="SettingsLegal" /> |
| 825 | 830 | {updateCheckAvailable ? ( |
| 826 | 831 | <Pressable |
apps/mobile/src/features/settings/components/settings-sheet-targets.ts+1| ... | ... | @@ -4,6 +4,7 @@ export type SettingsSheetTarget = |
| 4 | 4 | | "SettingsAppearance" |
| 5 | 5 | | "SettingsProjectGrouping" |
| 6 | 6 | | "SettingsClientStorage" |
| 7 | | "SettingsOpenSourceLicenses" | |
| 7 | 8 | | "SettingsUsage"; |
| 8 | 9 | |
| 9 | 10 | export type SettingsLegalDocumentTarget = "SettingsLegal"; |
apps/mobile/src/features/settings/mobileThirdPartyLicenses.ts created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | import { | |
| 2 | decodeThirdPartyLicenseManifest, | |
| 3 | type ThirdPartyLicenseManifest, | |
| 4 | } from "@t3tools/shared/thirdPartyLicenses"; | |
| 5 | ||
| 6 | let cachedManifest: ThirdPartyLicenseManifest | undefined; | |
| 7 | ||
| 8 | export function getMobileThirdPartyLicenses(): ThirdPartyLicenseManifest { | |
| 9 | if (cachedManifest) return cachedManifest; | |
| 10 | const generatedManifest: unknown = require("@t3tools/mobile-third-party-licenses"); | |
| 11 | cachedManifest = decodeThirdPartyLicenseManifest(generatedManifest); | |
| 12 | return cachedManifest; | |
| 13 | } |
apps/mobile/src/lib/markdownLinks.test.ts+1-1| ... | ... | @@ -75,7 +75,7 @@ describe("resolveMarkdownLinkPresentation", () => { |
| 75 | 75 | expect(resolveMarkdownLinkPresentation("package.json")).toEqual({ |
| 76 | 76 | kind: "file", |
| 77 | 77 | href: "package.json", |
| 78 | icon: "package", | |
| 78 | icon: "npm", | |
| 79 | 79 | label: "package.json", |
| 80 | 80 | path: "package.json", |
| 81 | 81 | }); |
apps/mobile/src/lib/nativeMarkdownText.test.ts+1-1| ... | ... | @@ -95,7 +95,7 @@ describe("nativeMarkdownTextRuns", () => { |
| 95 | 95 | { |
| 96 | 96 | text: "README.md:12", |
| 97 | 97 | href: "file:///repo/README.md#L12", |
| 98 | fileIcon: "readme", | |
| 98 | fileIcon: "markdown", | |
| 99 | 99 | }, |
| 100 | 100 | ]); |
| 101 | 101 | }); |
apps/mobile/src/types/mobile-third-party-licenses.d.ts created+4| ... | ... | @@ -0,0 +1,4 @@ |
| 1 | declare module "@t3tools/mobile-third-party-licenses" { | |
| 2 | const manifest: unknown; | |
| 3 | export default manifest; | |
| 4 | } |
apps/web/THIRD_PARTY_NOTICES.md deleted-11| ... | ... | @@ -1,11 +0,0 @@ |
| 1 | # Third-Party Notices | |
| 2 | ||
| 3 | ## vscode-icons | |
| 4 | ||
| 5 | The custom file icon symbols in `src/pierre-icons.ts` are adapted from the | |
| 6 | [`vscode-icons`](https://github.com/vscode-icons/vscode-icons) project. | |
| 7 | ||
| 8 | Copyright (c) 2016 Roberto Huertas | |
| 9 | ||
| 10 | Licensed under the MIT License. The full license text is available in the | |
| 11 | upstream repository: <https://github.com/vscode-icons/vscode-icons/blob/master/LICENSE>. |
apps/web/src/components/settings/OpenSourceLicenses.tsx created+270| ... | ... | @@ -0,0 +1,270 @@ |
| 1 | import { ChevronRightIcon, ExternalLinkIcon, SearchIcon } from "lucide-react"; | |
| 2 | import { useCallback, useEffect, useMemo, useState } from "react"; | |
| 3 | ||
| 4 | import { | |
| 5 | decodeThirdPartyLicenseManifest, | |
| 6 | filterThirdPartyLicenseEntries, | |
| 7 | formatLicenseBundles, | |
| 8 | thirdPartyLicenseEntryKey, | |
| 9 | type ThirdPartyLicenseEntry, | |
| 10 | type ThirdPartyLicenseManifest, | |
| 11 | } from "@t3tools/shared/thirdPartyLicenses"; | |
| 12 | ||
| 13 | import { Button } from "../ui/button"; | |
| 14 | import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; | |
| 15 | import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; | |
| 16 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 17 | import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; | |
| 18 | ||
| 19 | type LicenseManifestState = | |
| 20 | | { readonly status: "loading" } | |
| 21 | | { readonly status: "error"; readonly message: string } | |
| 22 | | { readonly status: "ready"; readonly manifest: ThirdPartyLicenseManifest }; | |
| 23 | ||
| 24 | async function loadLicenseManifest(signal: AbortSignal): Promise<ThirdPartyLicenseManifest> { | |
| 25 | const response = await fetch( | |
| 26 | `${import.meta.env.BASE_URL.replace(/\/$/, "")}/third-party-licenses.json`, | |
| 27 | { signal }, | |
| 28 | ); | |
| 29 | if (!response.ok) { | |
| 30 | throw new Error(`The license manifest request failed with status ${String(response.status)}.`); | |
| 31 | } | |
| 32 | return decodeThirdPartyLicenseManifest((await response.json()) as unknown); | |
| 33 | } | |
| 34 | ||
| 35 | function LicenseNoticeRow({ | |
| 36 | entry, | |
| 37 | open, | |
| 38 | onOpenChange, | |
| 39 | }: { | |
| 40 | readonly entry: ThirdPartyLicenseEntry; | |
| 41 | readonly open: boolean; | |
| 42 | readonly onOpenChange: (open: boolean) => void; | |
| 43 | }) { | |
| 44 | return ( | |
| 45 | <Collapsible open={open} onOpenChange={onOpenChange}> | |
| 46 | <article> | |
| 47 | <div className="flex min-h-10 items-center hover:bg-muted/35 sm:min-h-9"> | |
| 48 | <CollapsibleTrigger className="group flex min-h-10 min-w-0 flex-1 items-center gap-2.5 px-3 text-left sm:min-h-9 sm:px-4"> | |
| 49 | <ChevronRightIcon | |
| 50 | aria-hidden | |
| 51 | className="size-3.5 shrink-0 text-muted-foreground transition-transform duration-200 group-data-panel-open:rotate-90" | |
| 52 | /> | |
| 53 | <span className="flex min-w-0 flex-1 items-baseline gap-2"> | |
| 54 | <span className="truncate font-medium text-foreground">{entry.name}</span> | |
| 55 | {entry.version ? ( | |
| 56 | <code className="shrink-0 text-xs text-muted-foreground">{entry.version}</code> | |
| 57 | ) : null} | |
| 58 | </span> | |
| 59 | <span className="max-w-[42%] shrink-0 truncate text-xs text-muted-foreground"> | |
| 60 | {entry.license} · {formatLicenseBundles(entry.bundles)} | |
| 61 | </span> | |
| 62 | </CollapsibleTrigger> | |
| 63 | {entry.sourceUrl ? ( | |
| 64 | <Button | |
| 65 | aria-label={`View project source for ${entry.name}`} | |
| 66 | className="me-3 shrink-0 sm:me-4" | |
| 67 | render={<a href={entry.sourceUrl} rel="noreferrer noopener" target="_blank" />} | |
| 68 | size="icon-micro" | |
| 69 | title="Project source" | |
| 70 | variant="ghost-muted" | |
| 71 | > | |
| 72 | <ExternalLinkIcon aria-hidden className="size-3" /> | |
| 73 | </Button> | |
| 74 | ) : null} | |
| 75 | </div> | |
| 76 | <CollapsiblePanel> | |
| 77 | {open ? ( | |
| 78 | <div className="px-9 pt-1 pb-4 sm:px-10"> | |
| 79 | <pre className="max-w-[76ch] whitespace-pre-wrap break-words font-mono text-xs/5 text-foreground/80"> | |
| 80 | {entry.noticeText} | |
| 81 | </pre> | |
| 82 | </div> | |
| 83 | ) : null} | |
| 84 | </CollapsiblePanel> | |
| 85 | </article> | |
| 86 | </Collapsible> | |
| 87 | ); | |
| 88 | } | |
| 89 | ||
| 90 | function LicenseCount({ | |
| 91 | filteredCount, | |
| 92 | totalCount, | |
| 93 | }: { | |
| 94 | filteredCount: number; | |
| 95 | totalCount: number; | |
| 96 | }) { | |
| 97 | return ( | |
| 98 | <p className="whitespace-nowrap text-xs font-normal text-muted-foreground tabular-nums"> | |
| 99 | {filteredCount === totalCount | |
| 100 | ? `${String(totalCount)} notices` | |
| 101 | : `${String(filteredCount)} of ${String(totalCount)}`} | |
| 102 | </p> | |
| 103 | ); | |
| 104 | } | |
| 105 | ||
| 106 | function LicenseHeaderAction({ | |
| 107 | query, | |
| 108 | onQueryChange, | |
| 109 | searchOpen, | |
| 110 | onSearchOpenChange, | |
| 111 | filteredCount, | |
| 112 | totalCount, | |
| 113 | }: { | |
| 114 | query: string; | |
| 115 | onQueryChange: (value: string) => void; | |
| 116 | searchOpen: boolean; | |
| 117 | onSearchOpenChange: (open: boolean) => void; | |
| 118 | filteredCount: number; | |
| 119 | totalCount: number; | |
| 120 | }) { | |
| 121 | if (!searchOpen) { | |
| 122 | return ( | |
| 123 | <div className="flex items-center gap-1.5"> | |
| 124 | <LicenseCount filteredCount={filteredCount} totalCount={totalCount} /> | |
| 125 | <Tooltip> | |
| 126 | <TooltipTrigger | |
| 127 | render={ | |
| 128 | <Button | |
| 129 | aria-label="Search open-source licenses" | |
| 130 | onClick={() => onSearchOpenChange(true)} | |
| 131 | size="icon-micro" | |
| 132 | type="button" | |
| 133 | variant="ghost-muted" | |
| 134 | > | |
| 135 | <SearchIcon className="size-3" /> | |
| 136 | </Button> | |
| 137 | } | |
| 138 | /> | |
| 139 | <TooltipPopup side="top">Search licenses</TooltipPopup> | |
| 140 | </Tooltip> | |
| 141 | </div> | |
| 142 | ); | |
| 143 | } | |
| 144 | ||
| 145 | return ( | |
| 146 | <div className="flex items-center gap-2"> | |
| 147 | <div className="hidden sm:block"> | |
| 148 | <LicenseCount filteredCount={filteredCount} totalCount={totalCount} /> | |
| 149 | </div> | |
| 150 | <InputGroup className="w-36 sm:w-44"> | |
| 151 | <InputGroupAddon> | |
| 152 | <SearchIcon aria-hidden className="size-3" /> | |
| 153 | </InputGroupAddon> | |
| 154 | <InputGroupInput | |
| 155 | aria-label="Search open-source licenses" | |
| 156 | autoFocus | |
| 157 | onBlur={() => { | |
| 158 | if (query.length === 0) onSearchOpenChange(false); | |
| 159 | }} | |
| 160 | onChange={(event) => onQueryChange(event.currentTarget.value)} | |
| 161 | onKeyDown={(event) => { | |
| 162 | if (event.key !== "Escape") return; | |
| 163 | event.preventDefault(); | |
| 164 | onQueryChange(""); | |
| 165 | onSearchOpenChange(false); | |
| 166 | }} | |
| 167 | placeholder="Search licenses" | |
| 168 | size="sm" | |
| 169 | type="search" | |
| 170 | value={query} | |
| 171 | /> | |
| 172 | </InputGroup> | |
| 173 | </div> | |
| 174 | ); | |
| 175 | } | |
| 176 | ||
| 177 | function LicenseManifestError({ message, onRetry }: { message: string; onRetry: () => void }) { | |
| 178 | return ( | |
| 179 | <div className="flex flex-col items-start gap-3 px-3 py-5 sm:px-4"> | |
| 180 | <div className="flex flex-col gap-1"> | |
| 181 | <h3 className="text-sm font-medium text-foreground">Open-source notices are unavailable</h3> | |
| 182 | <p className="max-w-[70ch] text-pretty text-[13px] leading-[1.45] text-muted-foreground/80"> | |
| 183 | {message} | |
| 184 | </p> | |
| 185 | </div> | |
| 186 | <Button type="button" size="xs" variant="outline" onClick={onRetry}> | |
| 187 | Try again | |
| 188 | </Button> | |
| 189 | </div> | |
| 190 | ); | |
| 191 | } | |
| 192 | ||
| 193 | export function OpenSourceLicensesPanel() { | |
| 194 | const [state, setState] = useState<LicenseManifestState>({ status: "loading" }); | |
| 195 | const [query, setQuery] = useState(""); | |
| 196 | const [searchOpen, setSearchOpen] = useState(false); | |
| 197 | const [openEntryKey, setOpenEntryKey] = useState<string | null>(null); | |
| 198 | const [requestVersion, setRequestVersion] = useState(0); | |
| 199 | ||
| 200 | useEffect(() => { | |
| 201 | const controller = new AbortController(); | |
| 202 | setState({ status: "loading" }); | |
| 203 | void loadLicenseManifest(controller.signal).then( | |
| 204 | (manifest) => setState({ status: "ready", manifest }), | |
| 205 | (error: unknown) => { | |
| 206 | if (controller.signal.aborted) return; | |
| 207 | setState({ | |
| 208 | status: "error", | |
| 209 | message: error instanceof Error ? error.message : "The license manifest could not load.", | |
| 210 | }); | |
| 211 | }, | |
| 212 | ); | |
| 213 | return () => controller.abort(); | |
| 214 | }, [requestVersion]); | |
| 215 | ||
| 216 | const entries = state.status === "ready" ? state.manifest.entries : []; | |
| 217 | const filteredEntries = useMemo( | |
| 218 | () => filterThirdPartyLicenseEntries(entries, query), | |
| 219 | [entries, query], | |
| 220 | ); | |
| 221 | const retry = useCallback(() => setRequestVersion((value) => value + 1), []); | |
| 222 | ||
| 223 | return ( | |
| 224 | <SettingsPageContainer> | |
| 225 | <SettingsSection | |
| 226 | title="Third-party notices" | |
| 227 | headerAction={ | |
| 228 | state.status === "ready" ? ( | |
| 229 | <LicenseHeaderAction | |
| 230 | query={query} | |
| 231 | onQueryChange={setQuery} | |
| 232 | searchOpen={searchOpen} | |
| 233 | onSearchOpenChange={setSearchOpen} | |
| 234 | filteredCount={filteredEntries.length} | |
| 235 | totalCount={entries.length} | |
| 236 | /> | |
| 237 | ) : null | |
| 238 | } | |
| 239 | > | |
| 240 | {state.status === "ready" ? ( | |
| 241 | <div className="text-base sm:text-sm"> | |
| 242 | {filteredEntries.length > 0 ? ( | |
| 243 | filteredEntries.map((entry) => { | |
| 244 | const entryKey = thirdPartyLicenseEntryKey(entry); | |
| 245 | return ( | |
| 246 | <LicenseNoticeRow | |
| 247 | key={entryKey} | |
| 248 | entry={entry} | |
| 249 | open={openEntryKey === entryKey} | |
| 250 | onOpenChange={(open) => setOpenEntryKey(open ? entryKey : null)} | |
| 251 | /> | |
| 252 | ); | |
| 253 | }) | |
| 254 | ) : ( | |
| 255 | <p className="px-3 py-8 text-center text-sm/6 text-muted-foreground sm:px-4"> | |
| 256 | No licenses match that search. | |
| 257 | </p> | |
| 258 | )} | |
| 259 | </div> | |
| 260 | ) : state.status === "error" ? ( | |
| 261 | <LicenseManifestError message={state.message} onRetry={retry} /> | |
| 262 | ) : ( | |
| 263 | <p className="px-3 py-5 text-sm/6 text-muted-foreground sm:px-4"> | |
| 264 | Loading open-source notices… | |
| 265 | </p> | |
| 266 | )} | |
| 267 | </SettingsSection> | |
| 268 | </SettingsPageContainer> | |
| 269 | ); | |
| 270 | } |
apps/web/src/components/settings/SettingsBreadcrumb.tsx+1| ... | ... | @@ -36,6 +36,7 @@ import { |
| 36 | 36 | const SETTINGS_BREADCRUMB_LABELS: Readonly<Record<string, string>> = { |
| 37 | 37 | ...SETTINGS_SECTION_LABELS, |
| 38 | 38 | "/settings/diagnostics": "Diagnostics", |
| 39 | "/settings/open-source-licenses": "Open source licenses", | |
| 39 | 40 | }; |
| 40 | 41 | |
| 41 | 42 | function settingsBreadcrumbLabel(pathname: string): string | null { |
apps/web/src/components/settings/SettingsPanels.tsx+13| ... | ... | @@ -2907,6 +2907,19 @@ export function GeneralSettingsPanel() { |
| 2907 | 2907 | </Button> |
| 2908 | 2908 | } |
| 2909 | 2909 | /> |
| 2910 | <SettingsRow | |
| 2911 | {...searchableSetting("open-source-licenses")} | |
| 2912 | description="Notices for dependencies, assets, and optional tools used by T3 Code." | |
| 2913 | control={ | |
| 2914 | <Button | |
| 2915 | render={<Link to="/settings/open-source-licenses" />} | |
| 2916 | size="xs" | |
| 2917 | variant="outline" | |
| 2918 | > | |
| 2919 | View licenses | |
| 2920 | </Button> | |
| 2921 | } | |
| 2922 | /> | |
| 2910 | 2923 | </SettingsSection> |
| 2911 | 2924 | |
| 2912 | 2925 | <LegacyFeaturesSection /> |
apps/web/src/components/settings/SettingsSidebarNav.tsx+4-1| ... | ... | @@ -322,7 +322,10 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { |
| 322 | 322 | <SidebarMenu className="ps-px"> |
| 323 | 323 | {navItems.map((item) => { |
| 324 | 324 | const Icon = item.icon; |
| 325 | const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`); | |
| 325 | const isGeneralDetailPage = | |
| 326 | item.to === "/settings/general" && pathname === "/settings/open-source-licenses"; | |
| 327 | const isActive = | |
| 328 | isGeneralDetailPage || pathname === item.to || pathname.startsWith(`${item.to}/`); | |
| 326 | 329 | return ( |
| 327 | 330 | <SidebarMenuItem key={item.to}> |
| 328 | 331 | <SidebarMenuButton |
apps/web/src/components/settings/settingsSearch.ts+5| ... | ... | @@ -355,6 +355,11 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 355 | 355 | to: "/settings/general", |
| 356 | 356 | searchTerms: ["logs traces processes resource history failures spans cpu memory"], |
| 357 | 357 | }, |
| 358 | { | |
| 359 | id: "open-source-licenses", | |
| 360 | title: "Open source licenses", | |
| 361 | to: "/settings/general", | |
| 362 | }, | |
| 358 | 363 | { |
| 359 | 364 | id: "legacy-plan-mode", |
| 360 | 365 | title: "Plan mode (legacy)", |
apps/web/src/pierre-icons.test.ts+13-8| ... | ... | @@ -14,18 +14,21 @@ describe("Pierre file icons", () => { |
| 14 | 14 | assert.equal(resolvePierreIconForEntry("vite.config.ts", "file")?.token, "vite"); |
| 15 | 15 | }); |
| 16 | 16 | |
| 17 | it("extends Pierre with T3-specific exact filename icons", () => { | |
| 17 | it("uses built-in Pierre icons where available", () => { | |
| 18 | assert.equal(resolvePierreIconForEntry("package.json", "file")?.name, "file-tree-builtin-npm"); | |
| 18 | 19 | assert.equal( |
| 19 | resolvePierreIconForEntry("package.json", "file")?.name, | |
| 20 | "t3-file-icon-package-json", | |
| 20 | resolvePierreIconForEntry("config/tsconfig.json", "file")?.name, | |
| 21 | "file-tree-builtin-typescript", | |
| 21 | 22 | ); |
| 23 | assert.equal(resolvePierreIconForEntry("CLAUDE.md", "file")?.name, "file-tree-builtin-claude"); | |
| 22 | 24 | assert.equal( |
| 23 | resolvePierreIconForEntry("config/tsconfig.json", "file")?.name, | |
| 24 | "t3-file-icon-tsconfig", | |
| 25 | resolvePierreIconForEntry("README.md", "file")?.name, | |
| 26 | "file-tree-builtin-markdown", | |
| 25 | 27 | ); |
| 28 | }); | |
| 29 | ||
| 30 | it("extends Pierre with T3-specific exact filename icons", () => { | |
| 26 | 31 | assert.equal(resolvePierreIconForEntry("AGENTS.md", "file")?.name, "t3-file-icon-agents"); |
| 27 | assert.equal(resolvePierreIconForEntry("CLAUDE.md", "file")?.name, "t3-file-icon-claude"); | |
| 28 | assert.equal(resolvePierreIconForEntry("README.md", "file")?.name, "t3-file-icon-readme"); | |
| 29 | 32 | assert.equal(resolvePierreIconForEntry("pnpm-lock.yaml", "file")?.name, "t3-file-icon-pnpm"); |
| 30 | 33 | assert.equal( |
| 31 | 34 | resolvePierreIconForEntry("pnpm-workspace.yaml", "file")?.name, |
| ... | ... | @@ -34,7 +37,9 @@ describe("Pierre file icons", () => { |
| 34 | 37 | }); |
| 35 | 38 | |
| 36 | 39 | it("ships every custom icon referenced by the extended resolver", () => { |
| 37 | const customIconNames = new Set(Object.values(T3_PIERRE_ICONS.byFileName)); | |
| 40 | const customIconNames = new Set( | |
| 41 | Object.values(T3_PIERRE_ICONS.byFileName).filter((name) => name.startsWith("t3-")), | |
| 42 | ); | |
| 38 | 43 | for (const iconName of customIconNames) { |
| 39 | 44 | assert.include(T3_PIERRE_ICONS.spriteSheet, `id="${iconName}"`); |
| 40 | 45 | } |
apps/web/src/pierre-icons.ts+2-19| ... | ... | @@ -21,24 +21,9 @@ const T3_FILE_ICON_SPRITE = ` |
| 21 | 21 | <path d="M7 3v18M3 7.5h4M3 12h18M3 16.5h4M17 3v18M17 7.5h4M17 16.5h4" /> |
| 22 | 22 | </g> |
| 23 | 23 | </symbol> |
| 24 | <symbol id="t3-file-icon-package-json" viewBox="0 0 32 32"> | |
| 25 | <path d="M2 2H30V30H2" fill="#c12127" /> | |
| 26 | <path d="M7.25 7.25h17.5v17.5h-3.5v-14H16v14H7.25" fill="#fff" /> | |
| 27 | </symbol> | |
| 28 | <symbol id="t3-file-icon-tsconfig" viewBox="0 0 32 32"> | |
| 29 | <path d="M23.827 8.243a4.424 4.424 0 0 1 2.223 1.281 5.853 5.853 0 0 1 .852 1.143c.011.045-1.534 1.083-2.471 1.662-.034.023-.169-.124-.322-.35a2.014 2.014 0 0 0-1.67-1c-1.077-.074-1.771.49-1.766 1.433a1.3 1.3 0 0 0 .153.666c.237.49.677.784 2.059 1.383 2.544 1.1 3.636 1.817 4.31 2.843a5.158 5.158 0 0 1 .416 4.333 4.764 4.764 0 0 1-3.932 2.815 10.9 10.9 0 0 1-2.708-.028 6.531 6.531 0 0 1-3.616-1.884 6.278 6.278 0 0 1-.926-1.371 2.655 2.655 0 0 1 .327-.208c.158-.09.756-.434 1.32-.761l1.024-.6.214.312a4.771 4.771 0 0 0 1.35 1.292 3.3 3.3 0 0 0 3.458-.175 1.545 1.545 0 0 0 .2-1.974c-.276-.4-.84-.727-2.443-1.422a8.8 8.8 0 0 1-3.349-2.055 4.687 4.687 0 0 1-.976-1.777 7.116 7.116 0 0 1-.062-2.268 4.332 4.332 0 0 1 3.644-3.374 9 9 0 0 1 2.71.01ZM15.484 9.726l.011 1.454h-4.63v13.148H7.6V11.183H2.97V9.755a13.986 13.986 0 0 1 .04-1.466c.017-.023 2.832-.034 6.245-.028l6.211.017Z" fill="#007acc" /> | |
| 30 | <path d="m27.075 25.107.363-.361c1.68.055 1.706 0 1.78-.177l.462-1.124.034-.107-.038-.093c-.02-.049-.081-.2-1.13-1.2v-.526c1.211-1.166 1.185-1.226 1.116-1.4l-.46-1.136c-.069-.17-.1-.237-1.763-.191l-.364-.367a8.138 8.138 0 0 0-.057-1.657l-.047-.106-1.2-.525c-.177-.081-.239-.11-1.372 1.124l-.509-.008c-1.167-1.245-1.222-1.223-1.4-1.152l-1.115.452c-.175.071-.236.1-.169 1.79l-.36.359c-1.68-.055-1.7 0-1.778.177L18.606 20l-.036.108.038.094c.02.048.078.194 1.13 1.2v.525c-1.211 1.166-1.184 1.226-1.115 1.4l.459 1.137c.07.174.1.236 1.763.192l.363.377a8.169 8.169 0 0 0 .055 1.654l.047.107 1.208.528c.176.073.236.1 1.366-1.13l.509.006c1.168 1.247 1.228 1.223 1.4 1.154l1.113-.45c.176-.075.237-.102.169-1.795Zm-4.788-2.632a2 2 0 1 1 2.618 1.14 2.023 2.023 0 0 1-2.618-1.14Z" fill="#99b8c4" /> | |
| 31 | </symbol> | |
| 32 | 24 | <symbol id="t3-file-icon-agents" viewBox="0 0 32 32"> |
| 33 | 25 | <path fill="currentColor" d="M27.2 16c0-6.19-5.01-11.2-11.2-11.2C9.81 4.8 4.8 9.81 4.8 16S9.81 27.2 16 27.2c6.19 0 11.2-5.01 11.2-11.2Zm-5.6 2.1a1.4 1.4 0 1 1 0 2.8h-4.2a1.4 1.4 0 1 1 0-2.8Zm-11.2-6.8c.622-.373 1.42-.208 1.84.361l.079.119 2.1 3.5.088.171c.15.351.15.748 0 1.1l-.088.171-2.1 3.5a1.4 1.4 0 0 1-2.4-1.44L11.59 16l-1.67-2.78-.067-.127c-.302-.642-.075-1.42.547-1.79ZM30 16c0 7.73-6.27 14-14 14S2 23.73 2 16 8.27 2 16 2s14 6.27 14 14Z" /> |
| 34 | 26 | </symbol> |
| 35 | <symbol id="t3-file-icon-claude" viewBox="0 0 32 32"> | |
| 36 | <path fill="#d97757" d="m7.5 20.61 5.5-3.08.1-.27-.1-.15h-.27l-.92-.06a234.2 234.2 0 0 1-8.51-.34l-.67-.14-.62-.82.06-.41.56-.38.8.07c3.08.2 6.15.4 9.21.72h.46l.06-.19-.16-.1-.12-.12-2.74-1.86c-2.04-1.3-3.55-2.43-5.38-3.68l-.43-.54-.18-1.18.76-.84 1.03.07.26.07c2.03 1.6 4.1 3.14 6.17 4.66l.43.36.17-.12.02-.09-.2-.32c-1.43-2.6-2.55-4.62-4-6.96l-.2-.72c-.08-.3-.13-.55-.13-.85l.87-1.18.49-.16 1.16.16.49.42c1.4 3.31 2.76 5.93 4.23 8.83l.29.97.1.3h.19v-.17c.65-3.42 0-6.57 1.22-9.53l.87-.57.68.33.56.8-.08.51c-.37 2.62-.92 5.22-1.4 7.82h.24l.29-.29a68.66 68.66 0 0 1 4.91-5.94l.64-.5h1.2l.89 1.32-.4 1.36a53.45 53.45 0 0 0-4.66 6.47l.09.13.22-.02c2.4-.57 4.84-.99 7.27-1.4l.97.45.1.46-.37.94c-3 .72-6 1.34-9 2.05l-.05.04.06.07c2.7.26 5.62.3 7.99.47l.92.61.55.75-.1.56-1.4.73c-2.93-.7-5.19-1.22-7.91-1.9h-.22v.13c2.18 2.12 4.6 4.27 6.54 6.07l.15.68-.38.53-.4-.06c-2.04-1.43-3.9-3.09-5.8-4.7h-.15v.2l.52.76c1.14 1.79 2.64 3.25 2.87 5.37l-.2.41-.7.25-.78-.14a73.16 73.16 0 0 1-4.58-7.04l-.17.09-.78 8.46-.37.43-.85.33-.71-.54-.38-.87a114 114 0 0 0 1.53-7.97l.2-.73-.01-.05-.16.02a76.67 76.67 0 0 1-6.23 7.88l-.48.2-.84-.44.08-.77.47-.69c2.07-2.57 3.54-4.66 5.54-7v-.19h-.07l-7.4 4.8-1.31.17-.57-.53.07-.87.27-.28 2.23-1.53Z" /> | |
| 37 | </symbol> | |
| 38 | <symbol id="t3-file-icon-readme" viewBox="0 0 32 32"> | |
| 39 | <rect x="2.5" y="7.955" width="27" height="16.091" fill="none" stroke="#b48a5a" /> | |
| 40 | <path fill="#b48a5a" d="M5.909 20.636v-9.272h2.727l2.728 3.409 2.727-3.409h2.727v9.272h-2.727v-5.318l-2.727 3.409-2.728-3.409v5.318H5.91Zm17.046 0-4.091-4.5h2.727v-4.772h2.727v4.772h2.727l-4.09 4.5Z" /> | |
| 41 | </symbol> | |
| 42 | 27 | <symbol id="t3-file-icon-pnpm" viewBox="0 0 32 32"> |
| 43 | 28 | <path fill="#f9ad00" d="M30 10.75h-8.749V2H30Zm-9.626 0h-8.75V2h8.75Zm-9.625 0H2V2h8.749ZM30 20.375h-8.749v-8.75H30Z" /> |
| 44 | 29 | <path fill="currentColor" d="M20.374 20.375h-8.75v-8.75h8.75Zm0 9.625h-8.75v-8.75h8.75ZM30 30h-8.749v-8.75H30Zm-19.251 0H2v-8.75h8.749Z" /> |
| ... | ... | @@ -50,11 +35,9 @@ export const T3_PIERRE_ICONS = { |
| 50 | 35 | colored: true, |
| 51 | 36 | spriteSheet: T3_FILE_ICON_SPRITE, |
| 52 | 37 | byFileName: { |
| 53 | "package.json": "t3-file-icon-package-json", | |
| 54 | "tsconfig.json": "t3-file-icon-tsconfig", | |
| 38 | "package.json": "file-tree-builtin-npm", | |
| 39 | "tsconfig.json": "file-tree-builtin-typescript", | |
| 55 | 40 | "agents.md": "t3-file-icon-agents", |
| 56 | "claude.md": "t3-file-icon-claude", | |
| 57 | "readme.md": "t3-file-icon-readme", | |
| 58 | 41 | "pnpm-lock.yaml": "t3-file-icon-pnpm", |
| 59 | 42 | "pnpm-workspace.yaml": "t3-file-icon-pnpm", |
| 60 | 43 | }, |
apps/web/src/routeTree.gen.ts+22| ... | ... | @@ -20,6 +20,7 @@ import { Route as SettingsSourceControlRouteImport } from './routes/settings.sou |
| 20 | 20 | import { Route as SettingsSnapShotRouteImport } from './routes/settings.snap-shot' |
| 21 | 21 | import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' |
| 22 | 22 | import { Route as SettingsProjectsRouteImport } from './routes/settings.projects' |
| 23 | import { Route as SettingsOpenSourceLicensesRouteImport } from './routes/settings.open-source-licenses' | |
| 23 | 24 | import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' |
| 24 | 25 | import { Route as SettingsIntegrationsRouteImport } from './routes/settings.integrations' |
| 25 | 26 | import { Route as SettingsGeneralRouteImport } from './routes/settings.general' |
| ... | ... | @@ -87,6 +88,12 @@ const SettingsProjectsRoute = SettingsProjectsRouteImport.update({ |
| 87 | 88 | path: '/projects', |
| 88 | 89 | getParentRoute: () => SettingsRoute, |
| 89 | 90 | } as any) |
| 91 | const SettingsOpenSourceLicensesRoute = | |
| 92 | SettingsOpenSourceLicensesRouteImport.update({ | |
| 93 | id: '/open-source-licenses', | |
| 94 | path: '/open-source-licenses', | |
| 95 | getParentRoute: () => SettingsRoute, | |
| 96 | } as any) | |
| 90 | 97 | const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({ |
| 91 | 98 | id: '/keybindings', |
| 92 | 99 | path: '/keybindings', |
| ... | ... | @@ -166,6 +173,7 @@ export interface FileRoutesByFullPath { |
| 166 | 173 | '/settings/general': typeof SettingsGeneralRoute |
| 167 | 174 | '/settings/integrations': typeof SettingsIntegrationsRoute |
| 168 | 175 | '/settings/keybindings': typeof SettingsKeybindingsRoute |
| 176 | '/settings/open-source-licenses': typeof SettingsOpenSourceLicensesRoute | |
| 169 | 177 | '/settings/projects': typeof SettingsProjectsRoute |
| 170 | 178 | '/settings/providers': typeof SettingsProvidersRoute |
| 171 | 179 | '/settings/snap-shot': typeof SettingsSnapShotRoute |
| ... | ... | @@ -189,6 +197,7 @@ export interface FileRoutesByTo { |
| 189 | 197 | '/settings/general': typeof SettingsGeneralRoute |
| 190 | 198 | '/settings/integrations': typeof SettingsIntegrationsRoute |
| 191 | 199 | '/settings/keybindings': typeof SettingsKeybindingsRoute |
| 200 | '/settings/open-source-licenses': typeof SettingsOpenSourceLicensesRoute | |
| 192 | 201 | '/settings/projects': typeof SettingsProjectsRoute |
| 193 | 202 | '/settings/providers': typeof SettingsProvidersRoute |
| 194 | 203 | '/settings/snap-shot': typeof SettingsSnapShotRoute |
| ... | ... | @@ -215,6 +224,7 @@ export interface FileRoutesById { |
| 215 | 224 | '/settings/general': typeof SettingsGeneralRoute |
| 216 | 225 | '/settings/integrations': typeof SettingsIntegrationsRoute |
| 217 | 226 | '/settings/keybindings': typeof SettingsKeybindingsRoute |
| 227 | '/settings/open-source-licenses': typeof SettingsOpenSourceLicensesRoute | |
| 218 | 228 | '/settings/projects': typeof SettingsProjectsRoute |
| 219 | 229 | '/settings/providers': typeof SettingsProvidersRoute |
| 220 | 230 | '/settings/snap-shot': typeof SettingsSnapShotRoute |
| ... | ... | @@ -242,6 +252,7 @@ export interface FileRouteTypes { |
| 242 | 252 | | '/settings/general' |
| 243 | 253 | | '/settings/integrations' |
| 244 | 254 | | '/settings/keybindings' |
| 255 | | '/settings/open-source-licenses' | |
| 245 | 256 | | '/settings/projects' |
| 246 | 257 | | '/settings/providers' |
| 247 | 258 | | '/settings/snap-shot' |
| ... | ... | @@ -265,6 +276,7 @@ export interface FileRouteTypes { |
| 265 | 276 | | '/settings/general' |
| 266 | 277 | | '/settings/integrations' |
| 267 | 278 | | '/settings/keybindings' |
| 279 | | '/settings/open-source-licenses' | |
| 268 | 280 | | '/settings/projects' |
| 269 | 281 | | '/settings/providers' |
| 270 | 282 | | '/settings/snap-shot' |
| ... | ... | @@ -290,6 +302,7 @@ export interface FileRouteTypes { |
| 290 | 302 | | '/settings/general' |
| 291 | 303 | | '/settings/integrations' |
| 292 | 304 | | '/settings/keybindings' |
| 305 | | '/settings/open-source-licenses' | |
| 293 | 306 | | '/settings/projects' |
| 294 | 307 | | '/settings/providers' |
| 295 | 308 | | '/settings/snap-shot' |
| ... | ... | @@ -389,6 +402,13 @@ declare module '@tanstack/react-router' { |
| 389 | 402 | preLoaderRoute: typeof SettingsProjectsRouteImport |
| 390 | 403 | parentRoute: typeof SettingsRoute |
| 391 | 404 | } |
| 405 | '/settings/open-source-licenses': { | |
| 406 | id: '/settings/open-source-licenses' | |
| 407 | path: '/open-source-licenses' | |
| 408 | fullPath: '/settings/open-source-licenses' | |
| 409 | preLoaderRoute: typeof SettingsOpenSourceLicensesRouteImport | |
| 410 | parentRoute: typeof SettingsRoute | |
| 411 | } | |
| 392 | 412 | '/settings/keybindings': { |
| 393 | 413 | id: '/settings/keybindings' |
| 394 | 414 | path: '/keybindings' |
| ... | ... | @@ -500,6 +520,7 @@ interface SettingsRouteChildren { |
| 500 | 520 | SettingsGeneralRoute: typeof SettingsGeneralRoute |
| 501 | 521 | SettingsIntegrationsRoute: typeof SettingsIntegrationsRoute |
| 502 | 522 | SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute |
| 523 | SettingsOpenSourceLicensesRoute: typeof SettingsOpenSourceLicensesRoute | |
| 503 | 524 | SettingsProjectsRoute: typeof SettingsProjectsRoute |
| 504 | 525 | SettingsProvidersRoute: typeof SettingsProvidersRoute |
| 505 | 526 | SettingsSnapShotRoute: typeof SettingsSnapShotRoute |
| ... | ... | @@ -514,6 +535,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { |
| 514 | 535 | SettingsGeneralRoute: SettingsGeneralRoute, |
| 515 | 536 | SettingsIntegrationsRoute: SettingsIntegrationsRoute, |
| 516 | 537 | SettingsKeybindingsRoute: SettingsKeybindingsRoute, |
| 538 | SettingsOpenSourceLicensesRoute: SettingsOpenSourceLicensesRoute, | |
| 517 | 539 | SettingsProjectsRoute: SettingsProjectsRoute, |
| 518 | 540 | SettingsProvidersRoute: SettingsProvidersRoute, |
| 519 | 541 | SettingsSnapShotRoute: SettingsSnapShotRoute, |
apps/web/src/routes/settings.open-source-licenses.tsx created+7| ... | ... | @@ -0,0 +1,7 @@ |
| 1 | import { createFileRoute } from "@tanstack/react-router"; | |
| 2 | ||
| 3 | import { OpenSourceLicensesPanel } from "../components/settings/OpenSourceLicenses"; | |
| 4 | ||
| 5 | export const Route = createFileRoute("/settings/open-source-licenses")({ | |
| 6 | component: OpenSourceLicensesPanel, | |
| 7 | }); |
apps/web/tsconfig.json+2-1| ... | ... | @@ -32,6 +32,7 @@ |
| 32 | 32 | "vercel.ts", |
| 33 | 33 | "test", |
| 34 | 34 | "scripts/warm-dep-cache.ts", |
| 35 | "../../scripts/lib/public-config.ts" | |
| 35 | "../../scripts/lib/public-config.ts", | |
| 36 | "../../scripts/lib/third-party-licenses.ts" | |
| 36 | 37 | ] |
| 37 | 38 | } |
apps/web/vite.config.ts+10| ... | ... | @@ -12,6 +12,7 @@ import pkg from "./package.json" with { type: "json" }; |
| 12 | 12 | import { DEV_PROXIED_PATH_PREFIXES } from "@t3tools/shared/devProxy"; |
| 13 | 13 | |
| 14 | 14 | import { loadRepoEnv } from "../../scripts/lib/public-config"; |
| 15 | import { thirdPartyLicensesPlugin } from "../../scripts/lib/third-party-licenses"; | |
| 15 | 16 | import { tailwindPlugins } from "./vite/tailwind"; |
| 16 | 17 | |
| 17 | 18 | const repoEnv = loadRepoEnv(); |
| ... | ... | @@ -157,6 +158,15 @@ export default defineConfig(() => { |
| 157 | 158 | assetsInclude: ["**/*.wasm"], |
| 158 | 159 | plugins: [ |
| 159 | 160 | devCompressionPlugin(), |
| 161 | thirdPartyLicensesPlugin({ | |
| 162 | bundleName: "web", | |
| 163 | configFile: new URL("../../third-party-licenses.config.json", import.meta.url), | |
| 164 | packageManifests: [ | |
| 165 | { bundle: "web", path: new URL("./package.json", import.meta.url) }, | |
| 166 | { bundle: "server", path: new URL("../server/package.json", import.meta.url) }, | |
| 167 | { bundle: "desktop", path: new URL("../desktop/package.json", import.meta.url) }, | |
| 168 | ], | |
| 169 | }), | |
| 160 | 170 | // Route components load as split chunks so settings, pull-request, and |
| 161 | 171 | // usage code stay out of the cold-start payload; the router prefetches |
| 162 | 172 | // them on navigation intent (see getRouter's defaultPreload). |
docs/internals/open-source-licenses.md created+102| ... | ... | @@ -0,0 +1,102 @@ |
| 1 | # Open source license notices | |
| 2 | ||
| 3 | License notices are generated independently for the client that ships them: | |
| 4 | ||
| 5 | - The web build emits `third-party-licenses.json` beside `index.html`. The Settings page loads that | |
| 6 | static file, so the same artifact works in hosted web, the client bundled with `npx t3`, and | |
| 7 | desktop. | |
| 8 | - The mobile Metro config generates an ignored virtual module before each development, native, or | |
| 9 | over-the-air JavaScript bundle. Mobile loads and decodes that module only when a license screen | |
| 10 | opens, so the notice text does not occupy memory during ordinary app startup. It does not need a | |
| 11 | network request. | |
| 12 | ||
| 13 | Neither path depends on the connected environment or an RPC. | |
| 14 | ||
| 15 | ## What the build collects | |
| 16 | ||
| 17 | The generator follows installed production and optional dependencies, including dependencies of | |
| 18 | workspace packages, and omits first-party `@t3tools/*` packages. The web manifest starts from the | |
| 19 | web, server, and desktop package manifests. The mobile manifest starts from the mobile package | |
| 20 | manifest. During the web bundle, the generator also checks emitted module ids to catch a bundled | |
| 21 | npm import missing from a package manifest. | |
| 22 | ||
| 23 | The mobile manifest deliberately follows the complete production dependency closure declared by | |
| 24 | Expo and React Native. That is conservative and can include build tooling that is not present in | |
| 25 | the final JavaScript bundle, but it avoids dropping a notice when platform bundling changes. | |
| 26 | ||
| 27 | The build fails when a collected package has no distributable license identifier or contains no | |
| 28 | license or notice text. Generated notices use license templates from the pinned SPDX License List. | |
| 29 | Strict web and EAS builds download a missing template into the gitignored `.generated/` cache; | |
| 30 | `pnpm licenses:sync` can warm that cache explicitly. Local web and Metro development do not make a | |
| 31 | network request and omit generated rows until the cache exists. This keeps dev startup optional | |
| 32 | while preventing incomplete release artifacts. | |
| 33 | ||
| 34 | ## Custom notices and package overrides | |
| 35 | ||
| 36 | The repository-level `third-party-licenses.config.json` holds manually maintained exceptions for | |
| 37 | all clients. Add an entry to `customNotices` for adapted icons, fonts, media, native modules, or | |
| 38 | another asset that did not come from an npm package: | |
| 39 | ||
| 40 | ```json | |
| 41 | { | |
| 42 | "name": "asset-name", | |
| 43 | "license": "CC-BY-4.0", | |
| 44 | "generatedNotices": [ | |
| 45 | { | |
| 46 | "licenseId": "CC-BY-4.0", | |
| 47 | "preamble": ["Asset by Example Author. Changes: converted to MP3."] | |
| 48 | } | |
| 49 | ], | |
| 50 | "sourceUrl": "https://example.com/source", | |
| 51 | "bundles": ["assets", "web"] | |
| 52 | } | |
| 53 | ``` | |
| 54 | ||
| 55 | Each `generatedNotices` item names an SPDX license template and can add `copyrights` or a short | |
| 56 | `preamble` for attribution and provenance. Multiple items are joined into one row for software | |
| 57 | that vendors separately licensed code. Keep `noticeFile` or `noticeFiles` only when a vendored | |
| 58 | source tree already carries an intrinsic license file that should remain beside it. Paths are | |
| 59 | relative to the config file. `bundles` controls which generated manifests include the entry and | |
| 60 | supplies the label shown to users. Use `includeInBundles` when those differ, such as an optional | |
| 61 | server tool that should appear in both client manifests but is not bundled into either client. | |
| 62 | ||
| 63 | Use `packageOverrides` only when an installed npm archive omits its notice or has incorrect | |
| 64 | metadata: | |
| 65 | ||
| 66 | ```json | |
| 67 | { | |
| 68 | "name": "package-name", | |
| 69 | "version": "1.2.3", | |
| 70 | "generatedNotice": { | |
| 71 | "licenseId": "MIT", | |
| 72 | "copyrights": ["Copyright (c) 2026 Example Author"] | |
| 73 | }, | |
| 74 | "license": "MIT", | |
| 75 | "sourceUrl": "https://example.com/package-name" | |
| 76 | } | |
| 77 | ``` | |
| 78 | ||
| 79 | `version`, `license`, and `sourceUrl` are optional. Omitting `version` applies the override to every | |
| 80 | installed version of that package. An override can use `repositoryUrl` instead of `name` when | |
| 81 | several packages from one monorepo share the same notice: | |
| 82 | ||
| 83 | ```json | |
| 84 | { | |
| 85 | "repositoryUrl": "https://github.com/example/project", | |
| 86 | "generatedNotice": { | |
| 87 | "licenseId": "Apache-2.0" | |
| 88 | } | |
| 89 | } | |
| 90 | ``` | |
| 91 | ||
| 92 | The generator also reuses an installed sibling package's notice when both packages declare the | |
| 93 | same normalized repository and license. A name-and-version override always wins over these | |
| 94 | repository fallbacks. | |
| 95 | ||
| 96 | The `@react-grab/cli` override uses the root React Grab repository's MIT license because the CLI's | |
| 97 | npm archive omits both its license field and license file. Keep the override until the published | |
| 98 | CLI package carries that metadata itself. | |
| 99 | ||
| 100 | Generated mobile files live under `apps/mobile/.generated/`, while fetched SPDX templates live | |
| 101 | under the repository `.generated/` directory. Both are ignored. Do not commit or edit them; | |
| 102 | updating dependencies or configuration is enough for the next strict build to refresh the output. |
docs/user/open-source-licenses.md created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | # Open source licenses | |
| 2 | ||
| 3 | T3 Code includes third-party software and adapted assets. To read their license and attribution | |
| 4 | notices: | |
| 5 | ||
| 6 | - On web and desktop, open **Settings → General**, find **Open source licenses** under **About**, | |
| 7 | and select **View licenses**. | |
| 8 | - On mobile, open **Settings → App → Open source licenses**. | |
| 9 | ||
| 10 | The page lists the version when available, license identifier, and the parts of T3 Code that include | |
| 11 | or use the item. This also covers optional device tools that T3 Code installs on demand instead of | |
| 12 | bundling. Select a row to read the complete notice text. Use the search field to find a package, | |
| 13 | version, license, or app component. |
package.json+1| ... | ... | @@ -18,6 +18,7 @@ |
| 18 | 18 | "screenshots:mobile": "node scripts/mobile-showcase.ts", |
| 19 | 19 | "icons:export": "node scripts/export-brand-icons.ts", |
| 20 | 20 | "icons:check": "node scripts/export-brand-icons.ts --check", |
| 21 | "licenses:sync": "node scripts/sync-third-party-license-notices.ts", | |
| 21 | 22 | "icons:export:android": "node scripts/export-android-icons.ts", |
| 22 | 23 | "build": "vp run --filter './apps/*' build", |
| 23 | 24 | "build:marketing": "vp run --filter @t3tools/marketing build", |
packages/shared/package.json+4| ... | ... | @@ -275,6 +275,10 @@ |
| 275 | 275 | "types": "./src/desktopAppControl.ts", |
| 276 | 276 | "import": "./src/desktopAppControl.ts" |
| 277 | 277 | }, |
| 278 | "./thirdPartyLicenses": { | |
| 279 | "types": "./src/thirdPartyLicenses.ts", | |
| 280 | "import": "./src/thirdPartyLicenses.ts" | |
| 281 | }, | |
| 278 | 282 | "./claudeCompaction": { |
| 279 | 283 | "types": "./src/claudeCompaction.ts", |
| 280 | 284 | "import": "./src/claudeCompaction.ts" |
packages/shared/src/thirdPartyLicenses.test.ts created+91| ... | ... | @@ -0,0 +1,91 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | decodeThirdPartyLicenseManifest, | |
| 5 | filterThirdPartyLicenseEntries, | |
| 6 | findThirdPartyLicenseEntry, | |
| 7 | formatLicenseBundles, | |
| 8 | thirdPartyLicenseEntryKey, | |
| 9 | type ThirdPartyLicenseEntry, | |
| 10 | } from "./thirdPartyLicenses.js"; | |
| 11 | ||
| 12 | const ENTRIES: ReadonlyArray<ThirdPartyLicenseEntry> = [ | |
| 13 | { | |
| 14 | bundles: ["web"], | |
| 15 | kind: "package", | |
| 16 | license: "MIT", | |
| 17 | name: "react", | |
| 18 | noticeText: "React license", | |
| 19 | sourceUrl: "https://react.dev", | |
| 20 | version: "19.2.6", | |
| 21 | }, | |
| 22 | { | |
| 23 | bundles: ["assets", "mobile"], | |
| 24 | kind: "custom", | |
| 25 | license: "CC-BY-4.0", | |
| 26 | name: "sample-icons", | |
| 27 | noticeText: "Icon notice", | |
| 28 | sourceUrl: null, | |
| 29 | version: null, | |
| 30 | }, | |
| 31 | ]; | |
| 32 | ||
| 33 | describe("third-party license manifests", () => { | |
| 34 | it("decodes the generated manifest shape", () => { | |
| 35 | expect(decodeThirdPartyLicenseManifest({ schemaVersion: 1, entries: ENTRIES })).toEqual({ | |
| 36 | schemaVersion: 1, | |
| 37 | entries: ENTRIES, | |
| 38 | }); | |
| 39 | }); | |
| 40 | ||
| 41 | it("rejects unsupported manifest versions", () => { | |
| 42 | expect(() => decodeThirdPartyLicenseManifest({ schemaVersion: 2, entries: [] })).toThrow( | |
| 43 | "unsupported format", | |
| 44 | ); | |
| 45 | }); | |
| 46 | ||
| 47 | it("rejects unsafe source links", () => { | |
| 48 | expect(() => | |
| 49 | decodeThirdPartyLicenseManifest({ | |
| 50 | schemaVersion: 1, | |
| 51 | entries: [{ ...ENTRIES[0]!, sourceUrl: "javascript:alert(1)" }], | |
| 52 | }), | |
| 53 | ).toThrow("invalid shape"); | |
| 54 | }); | |
| 55 | ||
| 56 | it("rejects duplicate navigation keys", () => { | |
| 57 | expect(() => | |
| 58 | decodeThirdPartyLicenseManifest({ schemaVersion: 1, entries: [ENTRIES[0]!, ENTRIES[0]!] }), | |
| 59 | ).toThrow("duplicate entry"); | |
| 60 | }); | |
| 61 | ||
| 62 | it("filters by package, license, version, and bundle", () => { | |
| 63 | expect(filterThirdPartyLicenseEntries(ENTRIES, "react 19.2")).toEqual([ENTRIES[0]]); | |
| 64 | expect(filterThirdPartyLicenseEntries(ENTRIES, "cc-by mobile")).toEqual([ENTRIES[1]]); | |
| 65 | expect(filterThirdPartyLicenseEntries(ENTRIES, "apache")).toEqual([]); | |
| 66 | }); | |
| 67 | ||
| 68 | it("formats platform bundle names for display", () => { | |
| 69 | expect( | |
| 70 | formatLicenseBundles(["android", "assets", "ios", "mobile", "plugin", "constructor"]), | |
| 71 | ).toBe("Android, Assets, iOS, Mobile, plugin, constructor"); | |
| 72 | }); | |
| 73 | ||
| 74 | it("finds an entry by its stable navigation key", () => { | |
| 75 | const entry = ENTRIES[0]!; | |
| 76 | expect(findThirdPartyLicenseEntry(ENTRIES, thirdPartyLicenseEntryKey(entry))).toBe(entry); | |
| 77 | }); | |
| 78 | ||
| 79 | it("uses path-safe navigation keys for scoped packages", () => { | |
| 80 | expect(thirdPartyLicenseEntryKey({ ...ENTRIES[0]!, name: "@scope/package" })).not.toContain( | |
| 81 | "/", | |
| 82 | ); | |
| 83 | }); | |
| 84 | ||
| 85 | it("uses distinct navigation keys when names and versions contain delimiters", () => { | |
| 86 | const first = thirdPartyLicenseEntryKey({ ...ENTRIES[0]!, name: "a:1", version: null }); | |
| 87 | const second = thirdPartyLicenseEntryKey({ ...ENTRIES[0]!, name: "a", version: "1:custom" }); | |
| 88 | ||
| 89 | expect(first).not.toBe(second); | |
| 90 | }); | |
| 91 | }); |
packages/shared/src/thirdPartyLicenses.ts created+127| ... | ... | @@ -0,0 +1,127 @@ |
| 1 | export 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 | ||
| 11 | export interface ThirdPartyLicenseManifest { | |
| 12 | readonly schemaVersion: 1; | |
| 13 | readonly entries: ReadonlyArray<ThirdPartyLicenseEntry>; | |
| 14 | } | |
| 15 | ||
| 16 | function isRecord(value: unknown): value is Record<string, unknown> { | |
| 17 | return typeof value === "object" && value !== null && !Array.isArray(value); | |
| 18 | } | |
| 19 | ||
| 20 | function isStringArray(value: unknown): value is ReadonlyArray<string> { | |
| 21 | return Array.isArray(value) && value.every((entry) => typeof entry === "string"); | |
| 22 | } | |
| 23 | ||
| 24 | function 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 | ||
| 34 | function 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 | ||
| 61 | export 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 | ||
| 80 | export 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 | ||
| 99 | const 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 | ||
| 110 | export 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 | ||
| 118 | export function thirdPartyLicenseEntryKey(entry: ThirdPartyLicenseEntry): string { | |
| 119 | return encodeURIComponent(JSON.stringify([entry.kind, entry.name, entry.version])); | |
| 120 | } | |
| 121 | ||
| 122 | export function findThirdPartyLicenseEntry( | |
| 123 | entries: ReadonlyArray<ThirdPartyLicenseEntry>, | |
| 124 | key: string, | |
| 125 | ): ThirdPartyLicenseEntry | undefined { | |
| 126 | return entries.find((entry) => thirdPartyLicenseEntryKey(entry) === key); | |
| 127 | } |
scripts/lib/third-party-licenses.test.ts created+504| ... | ... | @@ -0,0 +1,504 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off - Tests exercise the Node filesystem build boundary. | |
| 2 | ||
| 3 | import * as NodeFSP from "node:fs/promises"; | |
| 4 | import * as NodeOS from "node:os"; | |
| 5 | import * as NodePath from "node:path"; | |
| 6 | import * as NodeURL from "node:url"; | |
| 7 | ||
| 8 | import { afterEach, describe, expect, it } from "vite-plus/test"; | |
| 9 | ||
| 10 | import { | |
| 11 | generateThirdPartyLicenseManifest, | |
| 12 | THIRD_PARTY_LICENSES_FILE_NAME, | |
| 13 | thirdPartyLicensesPlugin, | |
| 14 | } from "./third-party-licenses.js"; | |
| 15 | ||
| 16 | const tempDirectories: string[] = []; | |
| 17 | const REPOSITORY_ROOT = NodePath.resolve( | |
| 18 | NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), | |
| 19 | "../..", | |
| 20 | ); | |
| 21 | ||
| 22 | async function writeJson(path: string, value: unknown): Promise<void> { | |
| 23 | await NodeFSP.mkdir(NodePath.dirname(path), { recursive: true }); | |
| 24 | await NodeFSP.writeFile(path, `${JSON.stringify(value)}\n`, "utf8"); | |
| 25 | } | |
| 26 | ||
| 27 | async function createFixture(): Promise<{ | |
| 28 | readonly appManifest: string; | |
| 29 | readonly configFile: string; | |
| 30 | readonly dependencyRoot: string; | |
| 31 | readonly root: string; | |
| 32 | }> { | |
| 33 | const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3code-licenses-")); | |
| 34 | tempDirectories.push(root); | |
| 35 | const appManifest = NodePath.join(root, "package.json"); | |
| 36 | const dependencyRoot = NodePath.join(root, "node_modules", "demo-dependency"); | |
| 37 | const configFile = NodePath.join(root, "third-party-licenses.config.json"); | |
| 38 | ||
| 39 | await writeJson(appManifest, { | |
| 40 | name: "fixture-app", | |
| 41 | dependencies: { "demo-dependency": "1.2.3" }, | |
| 42 | }); | |
| 43 | await writeJson(NodePath.join(dependencyRoot, "package.json"), { | |
| 44 | name: "demo-dependency", | |
| 45 | version: "1.2.3", | |
| 46 | license: "MIT", | |
| 47 | main: "index.js", | |
| 48 | repository: "example/demo-dependency", | |
| 49 | }); | |
| 50 | await NodeFSP.writeFile(NodePath.join(dependencyRoot, "index.js"), "export {};\n", "utf8"); | |
| 51 | await NodeFSP.writeFile( | |
| 52 | NodePath.join(dependencyRoot, "LICENSE"), | |
| 53 | "Demo MIT license text\n", | |
| 54 | "utf8", | |
| 55 | ); | |
| 56 | await NodeFSP.writeFile(NodePath.join(root, "asset-notice.txt"), "Asset notice text\n", "utf8"); | |
| 57 | await writeJson(configFile, { | |
| 58 | customNotices: [ | |
| 59 | { | |
| 60 | name: "demo-asset", | |
| 61 | license: "CC-BY-4.0", | |
| 62 | noticeFile: "asset-notice.txt", | |
| 63 | bundles: ["assets", "web"], | |
| 64 | }, | |
| 65 | ], | |
| 66 | packageOverrides: [], | |
| 67 | }); | |
| 68 | return { appManifest, configFile, dependencyRoot, root }; | |
| 69 | } | |
| 70 | ||
| 71 | afterEach(async () => { | |
| 72 | await Promise.all( | |
| 73 | tempDirectories | |
| 74 | .splice(0) | |
| 75 | .map((directory) => NodeFSP.rm(directory, { force: true, recursive: true })), | |
| 76 | ); | |
| 77 | }); | |
| 78 | ||
| 79 | describe("third-party license generation", () => { | |
| 80 | it("keeps the GhosttyKit notice pinned to the vendored framework revision", async () => { | |
| 81 | const [config, revision] = await Promise.all([ | |
| 82 | NodeFSP.readFile(NodePath.join(REPOSITORY_ROOT, "third-party-licenses.config.json"), "utf8"), | |
| 83 | NodeFSP.readFile( | |
| 84 | NodePath.join(REPOSITORY_ROOT, "apps/mobile/modules/t3-terminal/Vendor/libghostty/VERSION"), | |
| 85 | "utf8", | |
| 86 | ), | |
| 87 | ]); | |
| 88 | ||
| 89 | expect(config).toContain(revision.trim()); | |
| 90 | expect(config).toContain( | |
| 91 | "https://github.com/Yash-Singh1/ghostty/tree/t3code/custom-io-ordered-feed", | |
| 92 | ); | |
| 93 | }); | |
| 94 | ||
| 95 | it("collects production packages and custom asset notices", async () => { | |
| 96 | const fixture = await createFixture(); | |
| 97 | const manifest = await generateThirdPartyLicenseManifest({ | |
| 98 | configFile: fixture.configFile, | |
| 99 | packageManifests: [{ bundle: "web", path: fixture.appManifest }], | |
| 100 | }); | |
| 101 | ||
| 102 | expect(manifest).toEqual({ | |
| 103 | schemaVersion: 1, | |
| 104 | entries: [ | |
| 105 | { | |
| 106 | bundles: ["assets", "web"], | |
| 107 | kind: "custom", | |
| 108 | license: "CC-BY-4.0", | |
| 109 | name: "demo-asset", | |
| 110 | noticeText: "Asset notice text", | |
| 111 | sourceUrl: null, | |
| 112 | version: null, | |
| 113 | }, | |
| 114 | { | |
| 115 | bundles: ["web"], | |
| 116 | kind: "package", | |
| 117 | license: "MIT", | |
| 118 | name: "demo-dependency", | |
| 119 | noticeText: "Demo MIT license text", | |
| 120 | sourceUrl: "https://github.com/example/demo-dependency", | |
| 121 | version: "1.2.3", | |
| 122 | }, | |
| 123 | ], | |
| 124 | }); | |
| 125 | }); | |
| 126 | ||
| 127 | it("renders generated notices from the ignored SPDX cache", async () => { | |
| 128 | const fixture = await createFixture(); | |
| 129 | await writeJson( | |
| 130 | NodePath.join(fixture.root, ".generated/third-party-licenses/spdx/v3.28.0/MIT.json"), | |
| 131 | { | |
| 132 | licenseId: "MIT", | |
| 133 | licenseText: "MIT License\n\nCopyright (c) <year> <copyright holders>\n\nPermission text", | |
| 134 | }, | |
| 135 | ); | |
| 136 | await writeJson(fixture.configFile, { | |
| 137 | customNotices: [ | |
| 138 | { | |
| 139 | name: "generated-asset", | |
| 140 | license: "MIT", | |
| 141 | generatedNotices: [ | |
| 142 | { | |
| 143 | licenseId: "MIT", | |
| 144 | copyrights: ["Copyright (c) 2026 Example Author"], | |
| 145 | preamble: ["Adapted for T3 Code."], | |
| 146 | }, | |
| 147 | ], | |
| 148 | bundles: ["assets", "web"], | |
| 149 | }, | |
| 150 | ], | |
| 151 | packageOverrides: [], | |
| 152 | }); | |
| 153 | ||
| 154 | const manifest = await generateThirdPartyLicenseManifest({ | |
| 155 | configFile: fixture.configFile, | |
| 156 | packageManifests: [{ bundle: "web", path: fixture.appManifest }], | |
| 157 | }); | |
| 158 | ||
| 159 | expect(manifest.entries.find((entry) => entry.name === "generated-asset")?.noticeText).toBe( | |
| 160 | "Adapted for T3 Code.\n\nMIT License\n\nCopyright (c) 2026 Example Author\n\nPermission text", | |
| 161 | ); | |
| 162 | }); | |
| 163 | ||
| 164 | it("omits generated rows without a cache during optional development", async () => { | |
| 165 | const fixture = await createFixture(); | |
| 166 | await writeJson(fixture.configFile, { | |
| 167 | customNotices: [ | |
| 168 | { | |
| 169 | name: "generated-asset", | |
| 170 | license: "MIT", | |
| 171 | generatedNotices: [{ licenseId: "MIT" }], | |
| 172 | bundles: ["assets", "web"], | |
| 173 | }, | |
| 174 | ], | |
| 175 | packageOverrides: [], | |
| 176 | }); | |
| 177 | ||
| 178 | const manifest = await generateThirdPartyLicenseManifest({ | |
| 179 | configFile: fixture.configFile, | |
| 180 | packageManifests: [{ bundle: "web", path: fixture.appManifest }], | |
| 181 | allowMissingGeneratedNotices: true, | |
| 182 | }); | |
| 183 | ||
| 184 | expect(manifest.entries.some((entry) => entry.name === "generated-asset")).toBe(false); | |
| 185 | }); | |
| 186 | ||
| 187 | it("finds packages whose exports hide both their manifest and entry point", async () => { | |
| 188 | const fixture = await createFixture(); | |
| 189 | await writeJson(NodePath.join(fixture.dependencyRoot, "package.json"), { | |
| 190 | name: "demo-dependency", | |
| 191 | version: "1.2.3", | |
| 192 | license: "MIT", | |
| 193 | exports: {}, | |
| 194 | repository: "example/demo-dependency", | |
| 195 | }); | |
| 196 | ||
| 197 | const manifest = await generateThirdPartyLicenseManifest({ | |
| 198 | configFile: fixture.configFile, | |
| 199 | packageManifests: [{ bundle: "web", path: fixture.appManifest }], | |
| 200 | }); | |
| 201 | ||
| 202 | expect(manifest.entries.some((entry) => entry.name === "demo-dependency")).toBe(true); | |
| 203 | }); | |
| 204 | ||
| 205 | it("includes custom notices selected by the dev server bundle", async () => { | |
| 206 | const fixture = await createFixture(); | |
| 207 | await writeJson(fixture.configFile, { | |
| 208 | customNotices: [ | |
| 209 | { | |
| 210 | name: "desktop-only-asset", | |
| 211 | license: "CC-BY-4.0", | |
| 212 | noticeFile: "asset-notice.txt", | |
| 213 | bundles: ["assets"], | |
| 214 | includeInBundles: ["desktop"], | |
| 215 | }, | |
| 216 | ], | |
| 217 | packageOverrides: [], | |
| 218 | }); | |
| 219 | ||
| 220 | const plugin = thirdPartyLicensesPlugin({ | |
| 221 | bundleName: "desktop", | |
| 222 | configFile: fixture.configFile, | |
| 223 | packageManifests: [{ bundle: "web", path: fixture.appManifest }], | |
| 224 | }); | |
| 225 | let middleware: | |
| 226 | | (( | |
| 227 | request: { readonly url: string }, | |
| 228 | response: { | |
| 229 | statusCode: number; | |
| 230 | setHeader(name: string, value: string): void; | |
| 231 | end(body: string): void; | |
| 232 | }, | |
| 233 | next: (error?: Error) => void, | |
| 234 | ) => void) | |
| 235 | | undefined; | |
| 236 | if (typeof plugin.configureServer !== "function") { | |
| 237 | throw new Error("Expected the license plugin to define a configureServer hook."); | |
| 238 | } | |
| 239 | plugin.configureServer.call( | |
| 240 | {} as never, | |
| 241 | { | |
| 242 | middlewares: { | |
| 243 | use(handler: typeof middleware) { | |
| 244 | middleware = handler; | |
| 245 | }, | |
| 246 | }, | |
| 247 | } as never, | |
| 248 | ); | |
| 249 | if (!middleware) throw new Error("Expected the license plugin to register middleware."); | |
| 250 | ||
| 251 | const responseBody = await new Promise<string>((resolve, reject) => { | |
| 252 | middleware!( | |
| 253 | { url: `/${THIRD_PARTY_LICENSES_FILE_NAME}` }, | |
| 254 | { | |
| 255 | statusCode: 0, | |
| 256 | setHeader() {}, | |
| 257 | end: resolve, | |
| 258 | }, | |
| 259 | (error) => reject(error ?? new Error("License middleware skipped the request.")), | |
| 260 | ); | |
| 261 | }); | |
| 262 | const manifest = JSON.parse(responseBody) as { entries: ReadonlyArray<{ name: string }> }; | |
| 263 | ||
| 264 | expect(manifest.entries.some((entry) => entry.name === "desktop-only-asset")).toBe(true); | |
| 265 | }); | |
| 266 | ||
| 267 | it("fails when a production package has no distributable notice text", async () => { | |
| 268 | const fixture = await createFixture(); | |
| 269 | await NodeFSP.rm(NodePath.join(fixture.dependencyRoot, "LICENSE")); | |
| 270 | ||
| 271 | await expect( | |
| 272 | generateThirdPartyLicenseManifest({ | |
| 273 | configFile: fixture.configFile, | |
| 274 | packageManifests: [{ bundle: "web", path: fixture.appManifest }], | |
| 275 | }), | |
| 276 | ).rejects.toThrow("does not include a license or notice file"); | |
| 277 | }); | |
| 278 | ||
| 279 | it("collects nested notices even when a package also has a root license", async () => { | |
| 280 | const fixture = await createFixture(); | |
| 281 | await NodeFSP.mkdir(NodePath.join(fixture.dependencyRoot, "dist", "third-party"), { | |
| 282 | recursive: true, | |
| 283 | }); | |
| 284 | await NodeFSP.mkdir(NodePath.join(fixture.dependencyRoot, "lib"), { recursive: true }); | |
| 285 | await NodeFSP.writeFile( | |
| 286 | NodePath.join(fixture.dependencyRoot, "dist", "third-party", "NOTICE.txt"), | |
| 287 | "Nested notice\n", | |
| 288 | "utf8", | |
| 289 | ); | |
| 290 | await NodeFSP.writeFile( | |
| 291 | NodePath.join(fixture.dependencyRoot, "lib", "license_header.js"), | |
| 292 | "require('not-a-license');\n", | |
| 293 | "utf8", | |
| 294 | ); | |
| 295 | ||
| 296 | const manifest = await generateThirdPartyLicenseManifest({ | |
| 297 | configFile: fixture.configFile, | |
| 298 | packageManifests: [{ bundle: "web", path: fixture.appManifest }], | |
| 299 | }); | |
| 300 | ||
| 301 | expect(manifest.entries.find((entry) => entry.name === "demo-dependency")?.noticeText).toBe( | |
| 302 | "dist/third-party/NOTICE.txt\n\nNested notice\n\n---\n\nLICENSE\n\nDemo MIT license text", | |
| 303 | ); | |
| 304 | }); | |
| 305 | ||
| 306 | it("uses package overrides for notices published outside the npm archive", async () => { | |
| 307 | const fixture = await createFixture(); | |
| 308 | await NodeFSP.rm(NodePath.join(fixture.dependencyRoot, "LICENSE")); | |
| 309 | await NodeFSP.writeFile(NodePath.join(fixture.root, "override.txt"), "Override text\n", "utf8"); | |
| 310 | await writeJson(fixture.configFile, { | |
| 311 | customNotices: [], | |
| 312 | packageOverrides: [ | |
| 313 | { | |
| 314 | name: "demo-dependency", | |
| 315 | noticeFile: "override.txt", | |
| 316 | }, | |
| 317 | ], | |
| 318 | }); | |
| 319 | ||
| 320 | const manifest = await generateThirdPartyLicenseManifest({ | |
| 321 | configFile: fixture.configFile, | |
| 322 | packageManifests: [{ bundle: "web", path: fixture.appManifest }], | |
| 323 | }); | |
| 324 | ||
| 325 | expect(manifest.entries[0]?.noticeText).toBe("Override text"); | |
| 326 | }); | |
| 327 | ||
| 328 | it("applies repository overrides across monorepo packages", async () => { | |
| 329 | const fixture = await createFixture(); | |
| 330 | await NodeFSP.rm(NodePath.join(fixture.dependencyRoot, "LICENSE")); | |
| 331 | await NodeFSP.writeFile( | |
| 332 | NodePath.join(fixture.root, "override.txt"), | |
| 333 | "Repository text\n", | |
| 334 | "utf8", | |
| 335 | ); | |
| 336 | await writeJson(fixture.configFile, { | |
| 337 | customNotices: [], | |
| 338 | packageOverrides: [ | |
| 339 | { | |
| 340 | repositoryUrl: "https://github.com/example/demo-dependency", | |
| 341 | noticeFile: "override.txt", | |
| 342 | }, | |
| 343 | ], | |
| 344 | }); | |
| 345 | ||
| 346 | const manifest = await generateThirdPartyLicenseManifest({ | |
| 347 | configFile: fixture.configFile, | |
| 348 | packageManifests: [{ bundle: "web", path: fixture.appManifest }], | |
| 349 | }); | |
| 350 | ||
| 351 | expect(manifest.entries[0]?.noticeText).toBe("Repository text"); | |
| 352 | }); | |
| 353 | ||
| 354 | it("reuses a repository license for packages from the same monorepo", async () => { | |
| 355 | const fixture = await createFixture(); | |
| 356 | const siblingRoot = NodePath.join(fixture.root, "node_modules", "demo-sibling"); | |
| 357 | await writeJson(fixture.appManifest, { | |
| 358 | name: "fixture-app", | |
| 359 | dependencies: { "demo-dependency": "1.2.3", "demo-sibling": "2.0.0" }, | |
| 360 | }); | |
| 361 | await writeJson(NodePath.join(siblingRoot, "package.json"), { | |
| 362 | name: "demo-sibling", | |
| 363 | version: "2.0.0", | |
| 364 | license: "MIT", | |
| 365 | main: "index.js", | |
| 366 | repository: "https://github.com/example/demo-dependency.git#main", | |
| 367 | }); | |
| 368 | await NodeFSP.writeFile(NodePath.join(siblingRoot, "index.js"), "export {};\n", "utf8"); | |
| 369 | ||
| 370 | const manifest = await generateThirdPartyLicenseManifest({ | |
| 371 | configFile: fixture.configFile, | |
| 372 | packageManifests: [{ bundle: "web", path: fixture.appManifest }], | |
| 373 | }); | |
| 374 | ||
| 375 | expect(manifest.entries.find((entry) => entry.name === "demo-sibling")?.noticeText).toBe( | |
| 376 | "Demo MIT license text", | |
| 377 | ); | |
| 378 | }); | |
| 379 | ||
| 380 | it("prefers version-specific repository overrides", async () => { | |
| 381 | const fixture = await createFixture(); | |
| 382 | await NodeFSP.writeFile(NodePath.join(fixture.root, "generic.txt"), "Generic text\n", "utf8"); | |
| 383 | await NodeFSP.writeFile(NodePath.join(fixture.root, "exact.txt"), "Exact text\n", "utf8"); | |
| 384 | await writeJson(fixture.configFile, { | |
| 385 | customNotices: [], | |
| 386 | packageOverrides: [ | |
| 387 | { | |
| 388 | repositoryUrl: "https://github.com/example/demo-dependency", | |
| 389 | noticeFile: "generic.txt", | |
| 390 | }, | |
| 391 | { | |
| 392 | repositoryUrl: "https://github.com/example/demo-dependency", | |
| 393 | version: "1.2.3", | |
| 394 | noticeFile: "exact.txt", | |
| 395 | }, | |
| 396 | ], | |
| 397 | }); | |
| 398 | ||
| 399 | const manifest = await generateThirdPartyLicenseManifest({ | |
| 400 | configFile: fixture.configFile, | |
| 401 | packageManifests: [{ bundle: "web", path: fixture.appManifest }], | |
| 402 | }); | |
| 403 | ||
| 404 | expect(manifest.entries[0]?.noticeText).toBe("Exact text"); | |
| 405 | }); | |
| 406 | ||
| 407 | it("omits custom notices for other bundles", async () => { | |
| 408 | const fixture = await createFixture(); | |
| 409 | await writeJson(fixture.configFile, { | |
| 410 | customNotices: [ | |
| 411 | { | |
| 412 | name: "web-only-asset", | |
| 413 | license: "MIT", | |
| 414 | noticeFile: "asset-notice.txt", | |
| 415 | bundles: ["assets", "web"], | |
| 416 | }, | |
| 417 | ], | |
| 418 | packageOverrides: [], | |
| 419 | }); | |
| 420 | ||
| 421 | const manifest = await generateThirdPartyLicenseManifest({ | |
| 422 | configFile: fixture.configFile, | |
| 423 | packageManifests: [{ bundle: "mobile", path: fixture.appManifest }], | |
| 424 | }); | |
| 425 | ||
| 426 | expect(manifest.entries.some((entry) => entry.name === "web-only-asset")).toBe(false); | |
| 427 | }); | |
| 428 | ||
| 429 | it("can show a multi-file notice under a label that differs from its client manifests", async () => { | |
| 430 | const fixture = await createFixture(); | |
| 431 | await NodeFSP.writeFile( | |
| 432 | NodePath.join(fixture.root, "tool-license.txt"), | |
| 433 | "Tool license\n", | |
| 434 | "utf8", | |
| 435 | ); | |
| 436 | await NodeFSP.writeFile( | |
| 437 | NodePath.join(fixture.root, "vendor-notice.txt"), | |
| 438 | "Vendor notice\n", | |
| 439 | "utf8", | |
| 440 | ); | |
| 441 | await writeJson(fixture.configFile, { | |
| 442 | customNotices: [ | |
| 443 | { | |
| 444 | name: "optional-tool", | |
| 445 | license: "MIT AND Apache-2.0", | |
| 446 | noticeFiles: ["tool-license.txt", "vendor-notice.txt"], | |
| 447 | bundles: ["device-tools"], | |
| 448 | includeInBundles: ["mobile", "web"], | |
| 449 | }, | |
| 450 | ], | |
| 451 | packageOverrides: [], | |
| 452 | }); | |
| 453 | ||
| 454 | const manifest = await generateThirdPartyLicenseManifest({ | |
| 455 | configFile: fixture.configFile, | |
| 456 | packageManifests: [{ bundle: "mobile", path: fixture.appManifest }], | |
| 457 | }); | |
| 458 | ||
| 459 | expect(manifest.entries.find((entry) => entry.name === "optional-tool")).toMatchObject({ | |
| 460 | bundles: ["device-tools"], | |
| 461 | noticeText: "Tool license\n\n---\n\nVendor notice", | |
| 462 | }); | |
| 463 | }); | |
| 464 | ||
| 465 | it("fails when a custom notice file is empty", async () => { | |
| 466 | const fixture = await createFixture(); | |
| 467 | await NodeFSP.writeFile(NodePath.join(fixture.root, "asset-notice.txt"), "\n", "utf8"); | |
| 468 | ||
| 469 | await expect( | |
| 470 | generateThirdPartyLicenseManifest({ | |
| 471 | configFile: fixture.configFile, | |
| 472 | packageManifests: [{ bundle: "web", path: fixture.appManifest }], | |
| 473 | }), | |
| 474 | ).rejects.toThrow('Custom third-party notice "demo-asset" is empty'); | |
| 475 | }); | |
| 476 | ||
| 477 | it("fails generation when custom notices produce duplicate navigation keys", async () => { | |
| 478 | const fixture = await createFixture(); | |
| 479 | await writeJson(fixture.configFile, { | |
| 480 | customNotices: [ | |
| 481 | { | |
| 482 | name: "duplicate-asset", | |
| 483 | license: "MIT", | |
| 484 | noticeFile: "asset-notice.txt", | |
| 485 | bundles: ["assets", "web"], | |
| 486 | }, | |
| 487 | { | |
| 488 | name: "duplicate-asset", | |
| 489 | license: "CC0-1.0", | |
| 490 | noticeFile: "asset-notice.txt", | |
| 491 | bundles: ["assets", "web"], | |
| 492 | }, | |
| 493 | ], | |
| 494 | packageOverrides: [], | |
| 495 | }); | |
| 496 | ||
| 497 | await expect( | |
| 498 | generateThirdPartyLicenseManifest({ | |
| 499 | configFile: fixture.configFile, | |
| 500 | packageManifests: [{ bundle: "web", path: fixture.appManifest }], | |
| 501 | }), | |
| 502 | ).rejects.toThrow("duplicate custom notice for duplicate-asset"); | |
| 503 | }); | |
| 504 | }); |
scripts/lib/third-party-licenses.ts created+1014| ... | ... | @@ -0,0 +1,1014 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off globalFetch:off - Vite's build plugin runs before an Effect runtime exists. | |
| 2 | ||
| 3 | import * as NodeFSP from "node:fs/promises"; | |
| 4 | import * as NodePath from "node:path"; | |
| 5 | import * as NodeURL from "node:url"; | |
| 6 | import * as NodeModule from "node:module"; | |
| 7 | ||
| 8 | import type { Plugin } from "vite-plus"; | |
| 9 | ||
| 10 | export const THIRD_PARTY_LICENSES_FILE_NAME = "third-party-licenses.json"; | |
| 11 | const SPDX_LICENSE_LIST_VERSION = "v3.28.0"; | |
| 12 | const SPDX_LICENSE_LIST_REVISION = "c4a7237ec8f4654e867546f9f409749300f1bf4c"; | |
| 13 | const GENERATED_NOTICE_CACHE_DIRECTORY = ".generated/third-party-licenses/spdx"; | |
| 14 | ||
| 15 | export interface ThirdPartyLicenseEntry { | |
| 16 | readonly bundles: ReadonlyArray<string>; | |
| 17 | readonly kind: "custom" | "package"; | |
| 18 | readonly license: string; | |
| 19 | readonly name: string; | |
| 20 | readonly noticeText: string; | |
| 21 | readonly sourceUrl: string | null; | |
| 22 | readonly version: string | null; | |
| 23 | } | |
| 24 | ||
| 25 | export interface ThirdPartyLicenseManifest { | |
| 26 | readonly schemaVersion: 1; | |
| 27 | readonly entries: ReadonlyArray<ThirdPartyLicenseEntry>; | |
| 28 | } | |
| 29 | ||
| 30 | export interface ThirdPartyLicensePackageManifest { | |
| 31 | readonly bundle: string; | |
| 32 | readonly path: string | URL; | |
| 33 | } | |
| 34 | ||
| 35 | export interface ThirdPartyLicensesPluginOptions { | |
| 36 | readonly configFile?: string | URL; | |
| 37 | readonly packageManifests: ReadonlyArray<ThirdPartyLicensePackageManifest>; | |
| 38 | readonly bundleName: string; | |
| 39 | } | |
| 40 | ||
| 41 | interface GeneratedNoticeConfigEntry { | |
| 42 | readonly copyrights?: ReadonlyArray<string>; | |
| 43 | readonly licenseId: string; | |
| 44 | readonly preamble?: ReadonlyArray<string>; | |
| 45 | } | |
| 46 | ||
| 47 | interface PackageJson { | |
| 48 | readonly dependencies?: Readonly<Record<string, string>>; | |
| 49 | readonly homepage?: unknown; | |
| 50 | readonly license?: unknown; | |
| 51 | readonly licenses?: unknown; | |
| 52 | readonly name?: unknown; | |
| 53 | readonly optionalDependencies?: Readonly<Record<string, string>>; | |
| 54 | readonly repository?: unknown; | |
| 55 | readonly version?: unknown; | |
| 56 | } | |
| 57 | ||
| 58 | interface CustomNoticeConfigEntry { | |
| 59 | readonly bundles?: ReadonlyArray<string>; | |
| 60 | readonly includeInBundles?: ReadonlyArray<string>; | |
| 61 | readonly license: string; | |
| 62 | readonly name: string; | |
| 63 | readonly generatedNotices?: ReadonlyArray<GeneratedNoticeConfigEntry>; | |
| 64 | readonly noticeFiles?: ReadonlyArray<string>; | |
| 65 | readonly sourceUrl?: string; | |
| 66 | readonly version?: string; | |
| 67 | } | |
| 68 | ||
| 69 | interface PackageNoticeOverrideConfigEntry { | |
| 70 | readonly generatedNotice?: GeneratedNoticeConfigEntry; | |
| 71 | readonly license?: string; | |
| 72 | readonly name?: string; | |
| 73 | readonly noticeFile?: string; | |
| 74 | readonly repositoryUrl?: string; | |
| 75 | readonly sourceUrl?: string; | |
| 76 | readonly version?: string; | |
| 77 | } | |
| 78 | ||
| 79 | interface ThirdPartyLicensesConfig { | |
| 80 | readonly customNotices: ReadonlyArray<CustomNoticeConfigEntry>; | |
| 81 | readonly packageOverrides: ReadonlyArray<PackageNoticeOverrideConfigEntry>; | |
| 82 | } | |
| 83 | ||
| 84 | interface SpdxLicenseDetails { | |
| 85 | readonly licenseId: string; | |
| 86 | readonly licenseText: string; | |
| 87 | } | |
| 88 | ||
| 89 | interface CollectedPackage { | |
| 90 | readonly bundles: Set<string>; | |
| 91 | readonly packageJson: PackageJson; | |
| 92 | readonly packageRoot: string; | |
| 93 | } | |
| 94 | ||
| 95 | interface PackageCollection { | |
| 96 | readonly byIdentity: Map<string, CollectedPackage>; | |
| 97 | } | |
| 98 | ||
| 99 | const EMPTY_CONFIG: ThirdPartyLicensesConfig = { | |
| 100 | customNotices: [], | |
| 101 | packageOverrides: [], | |
| 102 | }; | |
| 103 | ||
| 104 | const NOTICE_FILE_PATTERN = /^(?:licen[cs]e|copying|notice)(?:[._-].*)?$/i; | |
| 105 | const NOTICE_TEXT_EXTENSIONS = new Set([ | |
| 106 | "", | |
| 107 | ".0bsd", | |
| 108 | ".agpl", | |
| 109 | ".apache2", | |
| 110 | ".bsd", | |
| 111 | ".gpl", | |
| 112 | ".isc", | |
| 113 | ".lgpl", | |
| 114 | ".markdown", | |
| 115 | ".md", | |
| 116 | ".mit", | |
| 117 | ".mpl", | |
| 118 | ".mpl2", | |
| 119 | ".rst", | |
| 120 | ".txt", | |
| 121 | ".unlicense", | |
| 122 | ]); | |
| 123 | const FIRST_PARTY_PACKAGE_PREFIX = "@t3tools/"; | |
| 124 | ||
| 125 | function isNoticeTextFile(fileName: string): boolean { | |
| 126 | return ( | |
| 127 | NOTICE_FILE_PATTERN.test(fileName) && | |
| 128 | NOTICE_TEXT_EXTENSIONS.has(NodePath.extname(fileName).toLowerCase()) | |
| 129 | ); | |
| 130 | } | |
| 131 | ||
| 132 | function asPath(value: string | URL): string { | |
| 133 | return value instanceof URL ? NodeURL.fileURLToPath(value) : NodePath.resolve(value); | |
| 134 | } | |
| 135 | ||
| 136 | function isRecord(value: unknown): value is Record<string, unknown> { | |
| 137 | return typeof value === "object" && value !== null && !Array.isArray(value); | |
| 138 | } | |
| 139 | ||
| 140 | function readRequiredString(value: Record<string, unknown>, key: string, context: string): string { | |
| 141 | const field = value[key]; | |
| 142 | if (typeof field !== "string" || field.trim().length === 0) { | |
| 143 | throw new Error(`${context} must define a non-empty "${key}" string.`); | |
| 144 | } | |
| 145 | return field.trim(); | |
| 146 | } | |
| 147 | ||
| 148 | function readOptionalString( | |
| 149 | value: Record<string, unknown>, | |
| 150 | key: string, | |
| 151 | context: string, | |
| 152 | ): string | undefined { | |
| 153 | const field = value[key]; | |
| 154 | if (field === undefined) return undefined; | |
| 155 | if (typeof field !== "string" || field.trim().length === 0) { | |
| 156 | throw new Error(`${context} must define "${key}" as a non-empty string when present.`); | |
| 157 | } | |
| 158 | return field.trim(); | |
| 159 | } | |
| 160 | ||
| 161 | function readOptionalStringArray( | |
| 162 | value: Record<string, unknown>, | |
| 163 | key: string, | |
| 164 | context: string, | |
| 165 | ): ReadonlyArray<string> | undefined { | |
| 166 | const field = value[key]; | |
| 167 | if (field === undefined) return undefined; | |
| 168 | if ( | |
| 169 | !Array.isArray(field) || | |
| 170 | field.length === 0 || | |
| 171 | field.some((entry) => typeof entry !== "string" || entry.trim().length === 0) | |
| 172 | ) { | |
| 173 | throw new Error(`${context} must define "${key}" as a non-empty string array.`); | |
| 174 | } | |
| 175 | return field.map((entry) => (entry as string).trim()); | |
| 176 | } | |
| 177 | ||
| 178 | function decodeGeneratedNotice(value: unknown, context: string): GeneratedNoticeConfigEntry { | |
| 179 | if (!isRecord(value)) throw new Error(`${context} must be an object.`); | |
| 180 | const copyrights = readOptionalStringArray(value, "copyrights", context); | |
| 181 | const preamble = readOptionalStringArray(value, "preamble", context); | |
| 182 | return { | |
| 183 | licenseId: readRequiredString(value, "licenseId", context), | |
| 184 | ...(copyrights !== undefined ? { copyrights } : {}), | |
| 185 | ...(preamble !== undefined ? { preamble } : {}), | |
| 186 | }; | |
| 187 | } | |
| 188 | ||
| 189 | function decodeGeneratedNotices( | |
| 190 | value: unknown, | |
| 191 | context: string, | |
| 192 | ): ReadonlyArray<GeneratedNoticeConfigEntry> | undefined { | |
| 193 | if (value === undefined) return undefined; | |
| 194 | if (!Array.isArray(value) || value.length === 0) { | |
| 195 | throw new Error(`${context} must define "generatedNotices" as a non-empty array.`); | |
| 196 | } | |
| 197 | return value.map((entry, index) => | |
| 198 | decodeGeneratedNotice(entry, `${context} generated notice at index ${String(index)}`), | |
| 199 | ); | |
| 200 | } | |
| 201 | ||
| 202 | function decodeCustomNotices(value: unknown): ReadonlyArray<CustomNoticeConfigEntry> { | |
| 203 | if (value === undefined) return []; | |
| 204 | if (!Array.isArray(value)) { | |
| 205 | throw new Error('Third-party license config field "customNotices" must be an array.'); | |
| 206 | } | |
| 207 | return value.map((entry, index) => { | |
| 208 | const context = `Third-party custom notice at index ${String(index)}`; | |
| 209 | if (!isRecord(entry)) throw new Error(`${context} must be an object.`); | |
| 210 | const version = readOptionalString(entry, "version", context); | |
| 211 | const sourceUrl = readOptionalString(entry, "sourceUrl", context); | |
| 212 | const bundles = readOptionalStringArray(entry, "bundles", context); | |
| 213 | const includeInBundles = readOptionalStringArray(entry, "includeInBundles", context); | |
| 214 | const noticeFile = readOptionalString(entry, "noticeFile", context); | |
| 215 | const noticeFiles = readOptionalStringArray(entry, "noticeFiles", context); | |
| 216 | const generatedNotices = decodeGeneratedNotices(entry.generatedNotices, context); | |
| 217 | const noticeSourceCount = | |
| 218 | Number(noticeFile !== undefined) + | |
| 219 | Number(noticeFiles !== undefined) + | |
| 220 | Number(generatedNotices !== undefined); | |
| 221 | if (noticeSourceCount !== 1) { | |
| 222 | throw new Error( | |
| 223 | `${context} must define exactly one of "noticeFile", "noticeFiles", or "generatedNotices".`, | |
| 224 | ); | |
| 225 | } | |
| 226 | return { | |
| 227 | name: readRequiredString(entry, "name", context), | |
| 228 | license: readRequiredString(entry, "license", context), | |
| 229 | ...(noticeFile !== undefined ? { noticeFiles: [noticeFile] } : {}), | |
| 230 | ...(noticeFiles !== undefined ? { noticeFiles } : {}), | |
| 231 | ...(generatedNotices !== undefined ? { generatedNotices } : {}), | |
| 232 | ...(version !== undefined ? { version } : {}), | |
| 233 | ...(sourceUrl !== undefined ? { sourceUrl } : {}), | |
| 234 | ...(bundles !== undefined ? { bundles } : {}), | |
| 235 | ...(includeInBundles !== undefined ? { includeInBundles } : {}), | |
| 236 | }; | |
| 237 | }); | |
| 238 | } | |
| 239 | ||
| 240 | function decodePackageOverrides(value: unknown): ReadonlyArray<PackageNoticeOverrideConfigEntry> { | |
| 241 | if (value === undefined) return []; | |
| 242 | if (!Array.isArray(value)) { | |
| 243 | throw new Error('Third-party license config field "packageOverrides" must be an array.'); | |
| 244 | } | |
| 245 | return value.map((entry, index) => { | |
| 246 | const context = `Third-party package override at index ${String(index)}`; | |
| 247 | if (!isRecord(entry)) throw new Error(`${context} must be an object.`); | |
| 248 | const name = readOptionalString(entry, "name", context); | |
| 249 | const repositoryUrl = readOptionalString(entry, "repositoryUrl", context); | |
| 250 | if ((name === undefined) === (repositoryUrl === undefined)) { | |
| 251 | throw new Error(`${context} must define exactly one of "name" or "repositoryUrl".`); | |
| 252 | } | |
| 253 | const version = readOptionalString(entry, "version", context); | |
| 254 | const license = readOptionalString(entry, "license", context); | |
| 255 | const noticeFile = readOptionalString(entry, "noticeFile", context); | |
| 256 | const generatedNotice = | |
| 257 | entry.generatedNotice === undefined | |
| 258 | ? undefined | |
| 259 | : decodeGeneratedNotice(entry.generatedNotice, `${context} generated notice`); | |
| 260 | if (noticeFile !== undefined && generatedNotice !== undefined) { | |
| 261 | throw new Error(`${context} cannot define both "noticeFile" and "generatedNotice".`); | |
| 262 | } | |
| 263 | const sourceUrl = readOptionalString(entry, "sourceUrl", context); | |
| 264 | return { | |
| 265 | ...(name !== undefined ? { name } : {}), | |
| 266 | ...(repositoryUrl !== undefined ? { repositoryUrl } : {}), | |
| 267 | ...(version !== undefined ? { version } : {}), | |
| 268 | ...(license !== undefined ? { license } : {}), | |
| 269 | ...(noticeFile !== undefined ? { noticeFile } : {}), | |
| 270 | ...(generatedNotice !== undefined ? { generatedNotice } : {}), | |
| 271 | ...(sourceUrl !== undefined ? { sourceUrl } : {}), | |
| 272 | }; | |
| 273 | }); | |
| 274 | } | |
| 275 | ||
| 276 | async function readConfig(configFile: string | URL | undefined): Promise<{ | |
| 277 | readonly config: ThirdPartyLicensesConfig; | |
| 278 | readonly directory: string; | |
| 279 | }> { | |
| 280 | if (configFile === undefined) { | |
| 281 | return { config: EMPTY_CONFIG, directory: NodePath.resolve(".") }; | |
| 282 | } | |
| 283 | const configPath = asPath(configFile); | |
| 284 | const source = await NodeFSP.readFile(configPath, "utf8"); | |
| 285 | const decoded = JSON.parse(source) as unknown; | |
| 286 | if (!isRecord(decoded)) throw new Error("Third-party license config must contain an object."); | |
| 287 | return { | |
| 288 | config: { | |
| 289 | customNotices: decodeCustomNotices(decoded.customNotices), | |
| 290 | packageOverrides: decodePackageOverrides(decoded.packageOverrides), | |
| 291 | }, | |
| 292 | directory: NodePath.dirname(configPath), | |
| 293 | }; | |
| 294 | } | |
| 295 | ||
| 296 | function spdxLicenseCachePath(configDirectory: string, licenseId: string): string { | |
| 297 | return NodePath.join( | |
| 298 | configDirectory, | |
| 299 | GENERATED_NOTICE_CACHE_DIRECTORY, | |
| 300 | SPDX_LICENSE_LIST_VERSION, | |
| 301 | `${licenseId}.json`, | |
| 302 | ); | |
| 303 | } | |
| 304 | ||
| 305 | function decodeSpdxLicenseDetails(value: unknown, expectedLicenseId: string): SpdxLicenseDetails { | |
| 306 | if ( | |
| 307 | !isRecord(value) || | |
| 308 | value.licenseId !== expectedLicenseId || | |
| 309 | typeof value.licenseText !== "string" || | |
| 310 | value.licenseText.trim().length === 0 | |
| 311 | ) { | |
| 312 | throw new Error(`SPDX returned invalid license details for ${expectedLicenseId}.`); | |
| 313 | } | |
| 314 | return { licenseId: expectedLicenseId, licenseText: value.licenseText.trim() }; | |
| 315 | } | |
| 316 | ||
| 317 | async function readCachedSpdxLicense( | |
| 318 | configDirectory: string, | |
| 319 | licenseId: string, | |
| 320 | ): Promise<SpdxLicenseDetails | null> { | |
| 321 | try { | |
| 322 | const source = await NodeFSP.readFile(spdxLicenseCachePath(configDirectory, licenseId), "utf8"); | |
| 323 | return decodeSpdxLicenseDetails(JSON.parse(source) as unknown, licenseId); | |
| 324 | } catch (error) { | |
| 325 | const code = isRecord(error) && typeof error.code === "string" ? error.code : null; | |
| 326 | if (code === "ENOENT") return null; | |
| 327 | throw error; | |
| 328 | } | |
| 329 | } | |
| 330 | ||
| 331 | async function downloadSpdxLicense( | |
| 332 | configDirectory: string, | |
| 333 | licenseId: string, | |
| 334 | ): Promise<SpdxLicenseDetails> { | |
| 335 | const url = `https://raw.githubusercontent.com/spdx/license-list-data/${SPDX_LICENSE_LIST_REVISION}/json/details/${encodeURIComponent(licenseId)}.json`; | |
| 336 | const response = await fetch(url); | |
| 337 | if (!response.ok) { | |
| 338 | throw new Error( | |
| 339 | `Could not download SPDX license ${licenseId}: HTTP ${String(response.status)}.`, | |
| 340 | ); | |
| 341 | } | |
| 342 | const details = decodeSpdxLicenseDetails((await response.json()) as unknown, licenseId); | |
| 343 | const cachePath = spdxLicenseCachePath(configDirectory, licenseId); | |
| 344 | await NodeFSP.mkdir(NodePath.dirname(cachePath), { recursive: true }); | |
| 345 | await NodeFSP.writeFile(cachePath, `${JSON.stringify(details)}\n`, "utf8"); | |
| 346 | return details; | |
| 347 | } | |
| 348 | ||
| 349 | async function resolveSpdxLicense( | |
| 350 | configDirectory: string, | |
| 351 | licenseId: string, | |
| 352 | allowMissing: boolean, | |
| 353 | ): Promise<SpdxLicenseDetails | null> { | |
| 354 | const cached = await readCachedSpdxLicense(configDirectory, licenseId); | |
| 355 | if (cached || allowMissing) return cached; | |
| 356 | return downloadSpdxLicense(configDirectory, licenseId); | |
| 357 | } | |
| 358 | ||
| 359 | function renderGeneratedNotice(config: GeneratedNoticeConfigEntry, licenseText: string): string { | |
| 360 | const copyrights = config.copyrights ?? []; | |
| 361 | let renderedLicense = licenseText; | |
| 362 | if (copyrights.length > 0) { | |
| 363 | const placeholderPattern = /^Copyright[^\n]*(?:<year>|<copyright holders>|<owner>)[^\n]*$/m; | |
| 364 | if (placeholderPattern.test(renderedLicense)) { | |
| 365 | renderedLicense = renderedLicense.replace(placeholderPattern, copyrights.join("\n")); | |
| 366 | } else if (config.licenseId === "ISC") { | |
| 367 | renderedLicense = renderedLicense.replace( | |
| 368 | /^(?:Copyright[^\n]*\n)+/m, | |
| 369 | `${copyrights.join("\n")}\n`, | |
| 370 | ); | |
| 371 | } else { | |
| 372 | renderedLicense = `${copyrights.join("\n")}\n\n${renderedLicense}`; | |
| 373 | } | |
| 374 | } | |
| 375 | return [...(config.preamble ?? []), renderedLicense].join("\n\n").trim(); | |
| 376 | } | |
| 377 | ||
| 378 | async function generatedNoticeText( | |
| 379 | configs: ReadonlyArray<GeneratedNoticeConfigEntry>, | |
| 380 | configDirectory: string, | |
| 381 | allowMissing: boolean, | |
| 382 | ): Promise<string | null> { | |
| 383 | const sections = await Promise.all( | |
| 384 | configs.map(async (config) => { | |
| 385 | const details = await resolveSpdxLicense(configDirectory, config.licenseId, allowMissing); | |
| 386 | return details ? renderGeneratedNotice(config, details.licenseText) : null; | |
| 387 | }), | |
| 388 | ); | |
| 389 | return sections.some((section) => section === null) | |
| 390 | ? null | |
| 391 | : (sections as ReadonlyArray<string>).join("\n\n---\n\n"); | |
| 392 | } | |
| 393 | ||
| 394 | function configuredGeneratedNotices( | |
| 395 | config: ThirdPartyLicensesConfig, | |
| 396 | ): ReadonlyArray<GeneratedNoticeConfigEntry> { | |
| 397 | return [ | |
| 398 | ...config.customNotices.flatMap((notice) => notice.generatedNotices ?? []), | |
| 399 | ...config.packageOverrides.flatMap((override) => | |
| 400 | override.generatedNotice ? [override.generatedNotice] : [], | |
| 401 | ), | |
| 402 | ]; | |
| 403 | } | |
| 404 | ||
| 405 | async function syncConfiguredGeneratedNotices( | |
| 406 | config: ThirdPartyLicensesConfig, | |
| 407 | directory: string, | |
| 408 | ): Promise<void> { | |
| 409 | const licenseIds = [ | |
| 410 | ...new Set(configuredGeneratedNotices(config).map((notice) => notice.licenseId)), | |
| 411 | ].sort((left, right) => left.localeCompare(right)); | |
| 412 | await Promise.all(licenseIds.map((licenseId) => resolveSpdxLicense(directory, licenseId, false))); | |
| 413 | } | |
| 414 | ||
| 415 | export async function syncThirdPartyLicenseNotices(configFile: string | URL): Promise<void> { | |
| 416 | const { config, directory } = await readConfig(configFile); | |
| 417 | await syncConfiguredGeneratedNotices(config, directory); | |
| 418 | } | |
| 419 | ||
| 420 | async function readPackageJson(packageJsonPath: string): Promise<PackageJson> { | |
| 421 | const source = await NodeFSP.readFile(packageJsonPath, "utf8"); | |
| 422 | const value = JSON.parse(source) as unknown; | |
| 423 | if (!isRecord(value)) throw new Error(`Package manifest is not an object: ${packageJsonPath}`); | |
| 424 | return value as PackageJson; | |
| 425 | } | |
| 426 | ||
| 427 | function packageIdentity(packageJson: PackageJson, packageRoot: string): string { | |
| 428 | const name = | |
| 429 | typeof packageJson.name === "string" ? packageJson.name : NodePath.basename(packageRoot); | |
| 430 | const version = typeof packageJson.version === "string" ? packageJson.version : "unknown"; | |
| 431 | return `${name}@${version}`; | |
| 432 | } | |
| 433 | ||
| 434 | async function findPackageRoot( | |
| 435 | resolvedPath: string, | |
| 436 | expectedName?: string, | |
| 437 | ): Promise<{ readonly packageJson: PackageJson; readonly packageRoot: string } | null> { | |
| 438 | let current = NodePath.dirname(resolvedPath); | |
| 439 | const root = NodePath.parse(current).root; | |
| 440 | ||
| 441 | while (current !== root) { | |
| 442 | const packageJsonPath = NodePath.join(current, "package.json"); | |
| 443 | try { | |
| 444 | const packageJson = await readPackageJson(packageJsonPath); | |
| 445 | const matchesExpectedPackage = | |
| 446 | expectedName !== undefined | |
| 447 | ? packageJson.name === expectedName | |
| 448 | : typeof packageJson.name === "string" && typeof packageJson.version === "string"; | |
| 449 | if (matchesExpectedPackage) { | |
| 450 | return { packageJson, packageRoot: await NodeFSP.realpath(current) }; | |
| 451 | } | |
| 452 | } catch (error) { | |
| 453 | const code = isRecord(error) && typeof error.code === "string" ? error.code : null; | |
| 454 | if (code !== "ENOENT" && code !== "ENOTDIR") throw error; | |
| 455 | } | |
| 456 | current = NodePath.dirname(current); | |
| 457 | } | |
| 458 | return null; | |
| 459 | } | |
| 460 | ||
| 461 | async function resolveDependencyPackage( | |
| 462 | dependencyName: string, | |
| 463 | fromPackageJsonPath: string, | |
| 464 | ): Promise<{ readonly packageJson: PackageJson; readonly packageRoot: string } | null> { | |
| 465 | const requireFromPackage = NodeModule.createRequire(fromPackageJsonPath); | |
| 466 | const candidates = [`${dependencyName}/package.json`, dependencyName]; | |
| 467 | for (const candidate of candidates) { | |
| 468 | try { | |
| 469 | const resolved = requireFromPackage.resolve(candidate); | |
| 470 | const found = await findPackageRoot(resolved, dependencyName); | |
| 471 | if (found) return found; | |
| 472 | } catch (error) { | |
| 473 | const code = isRecord(error) && typeof error.code === "string" ? error.code : null; | |
| 474 | if (code !== "MODULE_NOT_FOUND" && code !== "ERR_PACKAGE_PATH_NOT_EXPORTED") throw error; | |
| 475 | } | |
| 476 | } | |
| 477 | ||
| 478 | let current = NodePath.dirname(fromPackageJsonPath); | |
| 479 | const root = NodePath.parse(current).root; | |
| 480 | while (true) { | |
| 481 | const packageRoot = NodePath.join(current, "node_modules", dependencyName); | |
| 482 | try { | |
| 483 | const packageJson = await readPackageJson(NodePath.join(packageRoot, "package.json")); | |
| 484 | if (packageJson.name === dependencyName) { | |
| 485 | return { packageJson, packageRoot: await NodeFSP.realpath(packageRoot) }; | |
| 486 | } | |
| 487 | } catch (error) { | |
| 488 | const code = isRecord(error) && typeof error.code === "string" ? error.code : null; | |
| 489 | if (code !== "ENOENT" && code !== "ENOTDIR") throw error; | |
| 490 | } | |
| 491 | if (current === root) break; | |
| 492 | current = NodePath.dirname(current); | |
| 493 | } | |
| 494 | return null; | |
| 495 | } | |
| 496 | ||
| 497 | function dependencyNames(packageJson: PackageJson): ReadonlyArray<string> { | |
| 498 | return [ | |
| 499 | ...new Set([ | |
| 500 | ...Object.keys(packageJson.dependencies ?? {}), | |
| 501 | ...Object.keys(packageJson.optionalDependencies ?? {}), | |
| 502 | ]), | |
| 503 | ].sort((left, right) => left.localeCompare(right)); | |
| 504 | } | |
| 505 | ||
| 506 | async function collectProductionDependencyPackages( | |
| 507 | packageManifests: ReadonlyArray<ThirdPartyLicensePackageManifest>, | |
| 508 | ): Promise<PackageCollection> { | |
| 509 | const collection: PackageCollection = { | |
| 510 | byIdentity: new Map(), | |
| 511 | }; | |
| 512 | const visited = new Set<string>(); | |
| 513 | ||
| 514 | const visitManifest = async (packageJsonPath: string, bundle: string): Promise<void> => { | |
| 515 | const packageJson = await readPackageJson(packageJsonPath); | |
| 516 | for (const dependencyName of dependencyNames(packageJson)) { | |
| 517 | const resolved = await resolveDependencyPackage(dependencyName, packageJsonPath); | |
| 518 | if (!resolved) continue; | |
| 519 | const visitKey = `${bundle}:${resolved.packageRoot}`; | |
| 520 | if (visited.has(visitKey)) continue; | |
| 521 | visited.add(visitKey); | |
| 522 | ||
| 523 | const dependencyPackageJsonPath = NodePath.join(resolved.packageRoot, "package.json"); | |
| 524 | const name = | |
| 525 | typeof resolved.packageJson.name === "string" ? resolved.packageJson.name : dependencyName; | |
| 526 | if (!name.startsWith(FIRST_PARTY_PACKAGE_PREFIX)) { | |
| 527 | const identity = packageIdentity(resolved.packageJson, resolved.packageRoot); | |
| 528 | const existing = collection.byIdentity.get(identity); | |
| 529 | if (existing) { | |
| 530 | existing.bundles.add(bundle); | |
| 531 | } else { | |
| 532 | collection.byIdentity.set(identity, { | |
| 533 | bundles: new Set([bundle]), | |
| 534 | packageJson: resolved.packageJson, | |
| 535 | packageRoot: resolved.packageRoot, | |
| 536 | }); | |
| 537 | } | |
| 538 | } | |
| 539 | ||
| 540 | await visitManifest(dependencyPackageJsonPath, bundle); | |
| 541 | } | |
| 542 | }; | |
| 543 | ||
| 544 | for (const manifest of packageManifests) { | |
| 545 | await visitManifest(asPath(manifest.path), manifest.bundle); | |
| 546 | } | |
| 547 | return collection; | |
| 548 | } | |
| 549 | ||
| 550 | function moduleFilePath(moduleId: string): string | null { | |
| 551 | if (moduleId.startsWith("\0") || moduleId.includes("\0")) return null; | |
| 552 | const withoutQuery = moduleId.split(/[?#]/, 1)[0] ?? moduleId; | |
| 553 | const viteFilePath = withoutQuery.startsWith("/@fs/") | |
| 554 | ? withoutQuery.slice("/@fs/".length) | |
| 555 | : withoutQuery; | |
| 556 | const filePath = withoutQuery.startsWith("file:") | |
| 557 | ? NodeURL.fileURLToPath(withoutQuery) | |
| 558 | : /^[A-Za-z]:[\\/]/.test(viteFilePath) | |
| 559 | ? viteFilePath | |
| 560 | : NodePath.resolve("/", viteFilePath); | |
| 561 | const normalized = filePath.replaceAll("\\", "/"); | |
| 562 | return normalized.includes("/node_modules/") ? filePath : null; | |
| 563 | } | |
| 564 | ||
| 565 | async function addBundledModulePackages( | |
| 566 | collection: PackageCollection, | |
| 567 | moduleIds: ReadonlyArray<string>, | |
| 568 | bundle: string, | |
| 569 | ): Promise<void> { | |
| 570 | for (const moduleId of moduleIds) { | |
| 571 | const filePath = moduleFilePath(moduleId); | |
| 572 | if (!filePath) continue; | |
| 573 | let found: Awaited<ReturnType<typeof findPackageRoot>>; | |
| 574 | try { | |
| 575 | found = await findPackageRoot(await NodeFSP.realpath(filePath)); | |
| 576 | } catch (error) { | |
| 577 | const code = isRecord(error) && typeof error.code === "string" ? error.code : null; | |
| 578 | if (code === "ENOENT" || code === "ENOTDIR") continue; | |
| 579 | throw error; | |
| 580 | } | |
| 581 | if (!found || typeof found.packageJson.name !== "string") continue; | |
| 582 | if (found.packageJson.name.startsWith(FIRST_PARTY_PACKAGE_PREFIX)) continue; | |
| 583 | const identity = packageIdentity(found.packageJson, found.packageRoot); | |
| 584 | const existing = collection.byIdentity.get(identity); | |
| 585 | if (existing) { | |
| 586 | existing.bundles.add(bundle); | |
| 587 | } else { | |
| 588 | collection.byIdentity.set(identity, { | |
| 589 | bundles: new Set([bundle]), | |
| 590 | packageJson: found.packageJson, | |
| 591 | packageRoot: found.packageRoot, | |
| 592 | }); | |
| 593 | } | |
| 594 | } | |
| 595 | } | |
| 596 | ||
| 597 | function normalizeLicense(packageJson: PackageJson): string | null { | |
| 598 | if (typeof packageJson.license === "string" && packageJson.license.trim().length > 0) { | |
| 599 | return packageJson.license.trim(); | |
| 600 | } | |
| 601 | if (isRecord(packageJson.license) && typeof packageJson.license.type === "string") { | |
| 602 | return packageJson.license.type.trim() || null; | |
| 603 | } | |
| 604 | const declaredLicenses = Array.isArray(packageJson.license) | |
| 605 | ? packageJson.license | |
| 606 | : packageJson.licenses; | |
| 607 | if (Array.isArray(declaredLicenses)) { | |
| 608 | const licenses = declaredLicenses | |
| 609 | .map((entry) => { | |
| 610 | if (typeof entry === "string") return entry.trim(); | |
| 611 | if (isRecord(entry) && typeof entry.type === "string") return entry.type.trim(); | |
| 612 | return ""; | |
| 613 | }) | |
| 614 | .filter((entry) => entry.length > 0); | |
| 615 | if (licenses.length > 0) return licenses.join(" OR "); | |
| 616 | } | |
| 617 | return null; | |
| 618 | } | |
| 619 | ||
| 620 | function normalizeRepositoryUrl(value: unknown): string | null { | |
| 621 | const raw = | |
| 622 | typeof value === "string" | |
| 623 | ? value | |
| 624 | : isRecord(value) && typeof value.url === "string" | |
| 625 | ? value.url | |
| 626 | : null; | |
| 627 | if (!raw) return null; | |
| 628 | const trimmed = raw.trim(); | |
| 629 | if (trimmed.startsWith("github:")) return `https://github.com/${trimmed.slice(7)}`; | |
| 630 | const normalized = trimmed | |
| 631 | .replace(/^git\+ssh:\/\/git@github\.com\//, "https://github.com/") | |
| 632 | .replace(/^git\+/, "") | |
| 633 | .replace(/^git@github\.com:/, "https://github.com/") | |
| 634 | .replace(/^ssh:\/\/(?:git@)?github\.com\//, "https://github.com/") | |
| 635 | .replace(/^git:\/\/github\.com\//, "https://github.com/") | |
| 636 | .replace(/#.*$/, "") | |
| 637 | .replace(/\.git$/, ""); | |
| 638 | if (/^[\w.-]+\/[\w.-]+$/.test(normalized)) return `https://github.com/${normalized}`; | |
| 639 | return normalized; | |
| 640 | } | |
| 641 | ||
| 642 | function packageSourceUrl(packageJson: PackageJson): string | null { | |
| 643 | if (typeof packageJson.homepage === "string" && packageJson.homepage.trim().length > 0) { | |
| 644 | return packageJson.homepage.trim(); | |
| 645 | } | |
| 646 | return normalizeRepositoryUrl(packageJson.repository); | |
| 647 | } | |
| 648 | ||
| 649 | async function readPackageNoticeText(packageRoot: string): Promise<string | null> { | |
| 650 | const noticeFiles: string[] = []; | |
| 651 | const rootEntries = await NodeFSP.readdir(packageRoot, { withFileTypes: true }); | |
| 652 | noticeFiles.push( | |
| 653 | ...rootEntries | |
| 654 | .filter((entry) => entry.isFile() && isNoticeTextFile(entry.name)) | |
| 655 | .map((entry) => entry.name), | |
| 656 | ); | |
| 657 | ||
| 658 | const collectNestedNoticeFiles = async (directory: string, depth: number): Promise<void> => { | |
| 659 | const directoryEntries = await NodeFSP.readdir(NodePath.join(packageRoot, directory), { | |
| 660 | withFileTypes: true, | |
| 661 | }); | |
| 662 | await Promise.all( | |
| 663 | directoryEntries.map(async (entry) => { | |
| 664 | const relativePath = NodePath.join(directory, entry.name); | |
| 665 | if (entry.isFile() && isNoticeTextFile(entry.name)) { | |
| 666 | noticeFiles.push(relativePath); | |
| 667 | return; | |
| 668 | } | |
| 669 | if ( | |
| 670 | depth > 0 && | |
| 671 | entry.isDirectory() && | |
| 672 | entry.name !== "node_modules" && | |
| 673 | entry.name !== ".git" | |
| 674 | ) { | |
| 675 | await collectNestedNoticeFiles(relativePath, depth - 1); | |
| 676 | } | |
| 677 | }), | |
| 678 | ); | |
| 679 | }; | |
| 680 | await Promise.all( | |
| 681 | rootEntries | |
| 682 | .filter( | |
| 683 | (entry) => entry.isDirectory() && entry.name !== "node_modules" && entry.name !== ".git", | |
| 684 | ) | |
| 685 | .map((entry) => collectNestedNoticeFiles(entry.name, 2)), | |
| 686 | ); | |
| 687 | noticeFiles.sort((left, right) => left.localeCompare(right)); | |
| 688 | if (noticeFiles.length === 0) return null; | |
| 689 | ||
| 690 | const sections: string[] = []; | |
| 691 | for (const fileName of noticeFiles) { | |
| 692 | const contents = (await NodeFSP.readFile(NodePath.join(packageRoot, fileName), "utf8")).trim(); | |
| 693 | if (contents.length === 0) continue; | |
| 694 | sections.push(noticeFiles.length === 1 ? contents : `${fileName}\n\n${contents}`); | |
| 695 | } | |
| 696 | return sections.length > 0 ? sections.join("\n\n---\n\n") : null; | |
| 697 | } | |
| 698 | ||
| 699 | function repositoryNoticeKey(packageJson: PackageJson, license: string): string | null { | |
| 700 | const repositoryUrl = normalizeRepositoryUrl(packageJson.repository); | |
| 701 | return repositoryUrl ? `${repositoryUrl.toLowerCase()}\n${license.toLowerCase()}` : null; | |
| 702 | } | |
| 703 | ||
| 704 | async function collectRepositoryNotices( | |
| 705 | collection: PackageCollection, | |
| 706 | packageNotices: Map<string, Promise<string | null>>, | |
| 707 | ): Promise<ReadonlyMap<string, string>> { | |
| 708 | const notices = new Map<string, string>(); | |
| 709 | const candidates = await Promise.all( | |
| 710 | [...collection.byIdentity.values()].map(async (collected) => { | |
| 711 | const license = normalizeLicense(collected.packageJson); | |
| 712 | if (!license) return null; | |
| 713 | const key = repositoryNoticeKey(collected.packageJson, license); | |
| 714 | if (!key) return null; | |
| 715 | const noticeText = await packageNoticeText(collected.packageRoot, packageNotices); | |
| 716 | return noticeText ? { key, noticeText } : null; | |
| 717 | }), | |
| 718 | ); | |
| 719 | for (const candidate of candidates) { | |
| 720 | if (candidate && !notices.has(candidate.key)) notices.set(candidate.key, candidate.noticeText); | |
| 721 | } | |
| 722 | return notices; | |
| 723 | } | |
| 724 | ||
| 725 | function packageNoticeText( | |
| 726 | packageRoot: string, | |
| 727 | cache: Map<string, Promise<string | null>>, | |
| 728 | ): Promise<string | null> { | |
| 729 | const existing = cache.get(packageRoot); | |
| 730 | if (existing) return existing; | |
| 731 | const notice = readPackageNoticeText(packageRoot); | |
| 732 | cache.set(packageRoot, notice); | |
| 733 | return notice; | |
| 734 | } | |
| 735 | ||
| 736 | function findPackageOverride( | |
| 737 | overrides: ReadonlyArray<PackageNoticeOverrideConfigEntry>, | |
| 738 | name: string, | |
| 739 | version: string, | |
| 740 | packageJson: PackageJson, | |
| 741 | ): PackageNoticeOverrideConfigEntry | undefined { | |
| 742 | const repositoryUrl = normalizeRepositoryUrl(packageJson.repository)?.toLowerCase(); | |
| 743 | const matchesRepository = (override: PackageNoticeOverrideConfigEntry) => | |
| 744 | repositoryUrl !== undefined && | |
| 745 | override.repositoryUrl !== undefined && | |
| 746 | normalizeRepositoryUrl(override.repositoryUrl)?.toLowerCase() === repositoryUrl; | |
| 747 | return ( | |
| 748 | overrides.find((override) => override.name === name && override.version === version) ?? | |
| 749 | overrides.find((override) => override.name === name && override.version === undefined) ?? | |
| 750 | overrides.find((override) => matchesRepository(override) && override.version === version) ?? | |
| 751 | overrides.find((override) => matchesRepository(override) && override.version === undefined) | |
| 752 | ); | |
| 753 | } | |
| 754 | ||
| 755 | async function packageEntry( | |
| 756 | collected: CollectedPackage, | |
| 757 | config: ThirdPartyLicensesConfig, | |
| 758 | configDirectory: string, | |
| 759 | packageNotices: Map<string, Promise<string | null>>, | |
| 760 | repositoryNotices: ReadonlyMap<string, string>, | |
| 761 | allowMissingGeneratedNotices: boolean, | |
| 762 | ): Promise<ThirdPartyLicenseEntry | null> { | |
| 763 | const name = | |
| 764 | typeof collected.packageJson.name === "string" | |
| 765 | ? collected.packageJson.name | |
| 766 | : NodePath.basename(collected.packageRoot); | |
| 767 | const version = | |
| 768 | typeof collected.packageJson.version === "string" ? collected.packageJson.version : "unknown"; | |
| 769 | const override = findPackageOverride( | |
| 770 | config.packageOverrides, | |
| 771 | name, | |
| 772 | version, | |
| 773 | collected.packageJson, | |
| 774 | ); | |
| 775 | const license = override?.license ?? normalizeLicense(collected.packageJson); | |
| 776 | if (!license || /^(?:unlicensed|proprietary)$/i.test(license)) { | |
| 777 | throw new Error( | |
| 778 | `${name}@${version} does not declare a distributable license. Add a package override in the third-party license config if the package publishes its notice elsewhere.`, | |
| 779 | ); | |
| 780 | } | |
| 781 | ||
| 782 | const repositoryKey = repositoryNoticeKey(collected.packageJson, license); | |
| 783 | const noticeText = override?.generatedNotice | |
| 784 | ? await generatedNoticeText( | |
| 785 | [override.generatedNotice], | |
| 786 | configDirectory, | |
| 787 | allowMissingGeneratedNotices, | |
| 788 | ) | |
| 789 | : override?.noticeFile | |
| 790 | ? ( | |
| 791 | await NodeFSP.readFile(NodePath.resolve(configDirectory, override.noticeFile), "utf8") | |
| 792 | ).trim() | |
| 793 | : ((await packageNoticeText(collected.packageRoot, packageNotices)) ?? | |
| 794 | (repositoryKey ? repositoryNotices.get(repositoryKey) : undefined)); | |
| 795 | if (!noticeText) { | |
| 796 | if (override?.generatedNotice && allowMissingGeneratedNotices) return null; | |
| 797 | throw new Error( | |
| 798 | `${name}@${version} does not include a license or notice file. Add a package override with "noticeFile" or "generatedNotice" in the third-party license config.`, | |
| 799 | ); | |
| 800 | } | |
| 801 | ||
| 802 | return { | |
| 803 | bundles: [...collected.bundles].sort((left, right) => left.localeCompare(right)), | |
| 804 | kind: "package", | |
| 805 | license, | |
| 806 | name, | |
| 807 | noticeText, | |
| 808 | sourceUrl: override?.sourceUrl ?? packageSourceUrl(collected.packageJson), | |
| 809 | version, | |
| 810 | }; | |
| 811 | } | |
| 812 | ||
| 813 | async function customEntries( | |
| 814 | config: ThirdPartyLicensesConfig, | |
| 815 | configDirectory: string, | |
| 816 | includedBundles: ReadonlySet<string>, | |
| 817 | allowMissingGeneratedNotices: boolean, | |
| 818 | ): Promise<ReadonlyArray<ThirdPartyLicenseEntry>> { | |
| 819 | const entries = await Promise.all( | |
| 820 | config.customNotices | |
| 821 | .filter( | |
| 822 | (notice) => | |
| 823 | (notice.includeInBundles ?? notice.bundles) === undefined || | |
| 824 | (notice.includeInBundles ?? notice.bundles)?.some((bundle) => | |
| 825 | includedBundles.has(bundle), | |
| 826 | ), | |
| 827 | ) | |
| 828 | .map(async (notice) => { | |
| 829 | const noticeText = notice.generatedNotices | |
| 830 | ? await generatedNoticeText( | |
| 831 | notice.generatedNotices, | |
| 832 | configDirectory, | |
| 833 | allowMissingGeneratedNotices, | |
| 834 | ) | |
| 835 | : ( | |
| 836 | await Promise.all( | |
| 837 | notice.noticeFiles!.map(async (noticeFile) => { | |
| 838 | const contents = ( | |
| 839 | await NodeFSP.readFile(NodePath.resolve(configDirectory, noticeFile), "utf8") | |
| 840 | ).trim(); | |
| 841 | if (contents.length === 0) { | |
| 842 | throw new Error(`Custom third-party notice "${notice.name}" is empty.`); | |
| 843 | } | |
| 844 | return contents; | |
| 845 | }), | |
| 846 | ) | |
| 847 | ).join("\n\n---\n\n"); | |
| 848 | if (noticeText === null) { | |
| 849 | if (allowMissingGeneratedNotices) return null; | |
| 850 | throw new Error(`Could not generate custom third-party notice "${notice.name}".`); | |
| 851 | } | |
| 852 | if (noticeText.length === 0) { | |
| 853 | throw new Error(`Custom third-party notice "${notice.name}" is empty.`); | |
| 854 | } | |
| 855 | return { | |
| 856 | bundles: [...(notice.bundles ?? ["assets"])].sort((left, right) => | |
| 857 | left.localeCompare(right), | |
| 858 | ), | |
| 859 | kind: "custom" as const, | |
| 860 | license: notice.license, | |
| 861 | name: notice.name, | |
| 862 | noticeText, | |
| 863 | sourceUrl: notice.sourceUrl ?? null, | |
| 864 | version: notice.version ?? null, | |
| 865 | }; | |
| 866 | }), | |
| 867 | ); | |
| 868 | return entries.flatMap((entry) => (entry ? [entry] : [])); | |
| 869 | } | |
| 870 | ||
| 871 | function entrySort(left: ThirdPartyLicenseEntry, right: ThirdPartyLicenseEntry): number { | |
| 872 | return ( | |
| 873 | left.name.localeCompare(right.name) || | |
| 874 | (left.version ?? "").localeCompare(right.version ?? "") || | |
| 875 | left.kind.localeCompare(right.kind) | |
| 876 | ); | |
| 877 | } | |
| 878 | ||
| 879 | function assertUniqueEntries(entries: ReadonlyArray<ThirdPartyLicenseEntry>): void { | |
| 880 | const identities = new Set<string>(); | |
| 881 | for (const entry of entries) { | |
| 882 | const identity = JSON.stringify([entry.kind, entry.name, entry.version]); | |
| 883 | if (identities.has(identity)) { | |
| 884 | throw new Error( | |
| 885 | `Third-party license generation found a duplicate ${entry.kind} notice for ${entry.name}${entry.version ? `@${entry.version}` : ""}.`, | |
| 886 | ); | |
| 887 | } | |
| 888 | identities.add(identity); | |
| 889 | } | |
| 890 | } | |
| 891 | ||
| 892 | export async function generateThirdPartyLicenseManifest(input: { | |
| 893 | readonly configFile?: string | URL; | |
| 894 | readonly packageManifests: ReadonlyArray<ThirdPartyLicensePackageManifest>; | |
| 895 | readonly bundledModuleIds?: ReadonlyArray<string>; | |
| 896 | readonly bundleName?: string; | |
| 897 | readonly allowMissingGeneratedNotices?: boolean; | |
| 898 | }): Promise<ThirdPartyLicenseManifest> { | |
| 899 | const [{ config, directory }, collection] = await Promise.all([ | |
| 900 | readConfig(input.configFile), | |
| 901 | collectProductionDependencyPackages(input.packageManifests), | |
| 902 | ]); | |
| 903 | if (!(input.allowMissingGeneratedNotices ?? false)) { | |
| 904 | await syncConfiguredGeneratedNotices(config, directory); | |
| 905 | } | |
| 906 | if (input.bundledModuleIds && input.bundleName) { | |
| 907 | await addBundledModulePackages(collection, input.bundledModuleIds, input.bundleName); | |
| 908 | } | |
| 909 | ||
| 910 | const packageNotices = new Map<string, Promise<string | null>>(); | |
| 911 | const repositoryNotices = await collectRepositoryNotices(collection, packageNotices); | |
| 912 | ||
| 913 | const packageEntryResults = await Promise.allSettled( | |
| 914 | [...collection.byIdentity.values()].map((collected) => | |
| 915 | packageEntry( | |
| 916 | collected, | |
| 917 | config, | |
| 918 | directory, | |
| 919 | packageNotices, | |
| 920 | repositoryNotices, | |
| 921 | input.allowMissingGeneratedNotices ?? false, | |
| 922 | ), | |
| 923 | ), | |
| 924 | ); | |
| 925 | const failures = packageEntryResults.flatMap((result) => | |
| 926 | result.status === "rejected" | |
| 927 | ? [result.reason instanceof Error ? result.reason.message : String(result.reason)] | |
| 928 | : [], | |
| 929 | ); | |
| 930 | if (failures.length > 0) { | |
| 931 | throw new Error( | |
| 932 | `Third-party license generation found ${String(failures.length)} invalid package notice${failures.length === 1 ? "" : "s"}:\n${failures.map((failure) => `- ${failure}`).join("\n")}`, | |
| 933 | ); | |
| 934 | } | |
| 935 | const packageEntries = packageEntryResults.flatMap((result) => | |
| 936 | result.status === "fulfilled" && result.value ? [result.value] : [], | |
| 937 | ); | |
| 938 | const includedBundles = new Set(input.packageManifests.map((manifest) => manifest.bundle)); | |
| 939 | if (input.bundleName) includedBundles.add(input.bundleName); | |
| 940 | const manualEntries = await customEntries( | |
| 941 | config, | |
| 942 | directory, | |
| 943 | includedBundles, | |
| 944 | input.allowMissingGeneratedNotices ?? false, | |
| 945 | ); | |
| 946 | const entries = [...packageEntries, ...manualEntries].sort(entrySort); | |
| 947 | assertUniqueEntries(entries); | |
| 948 | return { | |
| 949 | schemaVersion: 1, | |
| 950 | entries, | |
| 951 | }; | |
| 952 | } | |
| 953 | ||
| 954 | function moduleIdsFromBundle(bundle: unknown): ReadonlyArray<string> { | |
| 955 | const ids = new Set<string>(); | |
| 956 | if (!isRecord(bundle)) return []; | |
| 957 | for (const output of Object.values(bundle)) { | |
| 958 | if (!isRecord(output) || output.type !== "chunk" || !isRecord(output.modules)) continue; | |
| 959 | for (const id of Object.keys(output.modules)) ids.add(id); | |
| 960 | } | |
| 961 | return [...ids]; | |
| 962 | } | |
| 963 | ||
| 964 | function serializeManifest(manifest: ThirdPartyLicenseManifest): string { | |
| 965 | return `${JSON.stringify(manifest)}\n`; | |
| 966 | } | |
| 967 | ||
| 968 | export function thirdPartyLicensesPlugin(options: ThirdPartyLicensesPluginOptions): Plugin { | |
| 969 | return { | |
| 970 | name: "t3code:third-party-licenses", | |
| 971 | configureServer(server) { | |
| 972 | let manifestPromise: Promise<ThirdPartyLicenseManifest> | null = null; | |
| 973 | server.middlewares.use((request, response, next) => { | |
| 974 | if (request.url?.split("?", 1)[0] !== `/${THIRD_PARTY_LICENSES_FILE_NAME}`) { | |
| 975 | next(); | |
| 976 | return; | |
| 977 | } | |
| 978 | manifestPromise ??= generateThirdPartyLicenseManifest({ | |
| 979 | packageManifests: options.packageManifests, | |
| 980 | bundleName: options.bundleName, | |
| 981 | allowMissingGeneratedNotices: true, | |
| 982 | ...(options.configFile !== undefined ? { configFile: options.configFile } : {}), | |
| 983 | }).catch((error: unknown) => { | |
| 984 | manifestPromise = null; | |
| 985 | throw error; | |
| 986 | }); | |
| 987 | void manifestPromise.then( | |
| 988 | (manifest) => { | |
| 989 | response.statusCode = 200; | |
| 990 | response.setHeader("Content-Type", "application/json; charset=utf-8"); | |
| 991 | response.setHeader("Cache-Control", "no-store"); | |
| 992 | response.end(serializeManifest(manifest)); | |
| 993 | }, | |
| 994 | (error: unknown) => { | |
| 995 | next(error instanceof Error ? error : new Error(String(error))); | |
| 996 | }, | |
| 997 | ); | |
| 998 | }); | |
| 999 | }, | |
| 1000 | async generateBundle(_outputOptions, bundle) { | |
| 1001 | const manifest = await generateThirdPartyLicenseManifest({ | |
| 1002 | packageManifests: options.packageManifests, | |
| 1003 | bundledModuleIds: moduleIdsFromBundle(bundle), | |
| 1004 | bundleName: options.bundleName, | |
| 1005 | ...(options.configFile !== undefined ? { configFile: options.configFile } : {}), | |
| 1006 | }); | |
| 1007 | this.emitFile({ | |
| 1008 | type: "asset", | |
| 1009 | fileName: THIRD_PARTY_LICENSES_FILE_NAME, | |
| 1010 | source: serializeManifest(manifest), | |
| 1011 | }); | |
| 1012 | }, | |
| 1013 | }; | |
| 1014 | } |
scripts/sync-third-party-license-notices.ts created+9| ... | ... | @@ -0,0 +1,9 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off - This is a build-time filesystem script. | |
| 2 | ||
| 3 | import * as NodePath from "node:path"; | |
| 4 | ||
| 5 | import { syncThirdPartyLicenseNotices } from "./lib/third-party-licenses.ts"; | |
| 6 | ||
| 7 | const configFile = NodePath.resolve("third-party-licenses.config.json"); | |
| 8 | ||
| 9 | await syncThirdPartyLicenseNotices(configFile); |
third-party-licenses.config.json created+450| ... | ... | @@ -0,0 +1,450 @@ |
| 1 | { | |
| 2 | "customNotices": [ | |
| 3 | { | |
| 4 | "bundles": ["assets", "desktop", "web"], | |
| 5 | "license": "CC0-1.0", | |
| 6 | "name": "Folder_Whoosh.wav by BaggoNotes", | |
| 7 | "sourceUrl": "https://freesound.org/people/BaggoNotes/sounds/704258/", | |
| 8 | "generatedNotices": [ | |
| 9 | { | |
| 10 | "licenseId": "CC0-1.0", | |
| 11 | "preamble": [ | |
| 12 | "The bundled snap-shot-whoosh.mp3 is derived from Folder_Whoosh.wav by BaggoNotes:\nhttps://freesound.org/people/BaggoNotes/sounds/704258/\n\nThe original sound is dedicated to the public domain under CC0 1.0 Universal." | |
| 13 | ] | |
| 14 | } | |
| 15 | ] | |
| 16 | }, | |
| 17 | { | |
| 18 | "bundles": ["assets", "desktop", "web"], | |
| 19 | "license": "CC0-1.0", | |
| 20 | "name": "Contarex camera shutter.wav by Tonik1105", | |
| 21 | "sourceUrl": "https://freesound.org/people/Tonik1105/sounds/520684/", | |
| 22 | "generatedNotices": [ | |
| 23 | { | |
| 24 | "licenseId": "CC0-1.0", | |
| 25 | "preamble": [ | |
| 26 | "The bundled snap-shot-click.mp3 is derived from Contarex camera shutter.wav by Tonik1105:\nhttps://freesound.org/people/Tonik1105/sounds/520684/\n\nThe original sound is dedicated to the public domain under CC0 1.0 Universal." | |
| 27 | ] | |
| 28 | } | |
| 29 | ] | |
| 30 | }, | |
| 31 | { | |
| 32 | "bundles": ["device-tools"], | |
| 33 | "includeInBundles": ["mobile", "web"], | |
| 34 | "license": "MIT", | |
| 35 | "name": "agent-device", | |
| 36 | "sourceUrl": "https://github.com/callstack/agent-device", | |
| 37 | "version": "0.20.10", | |
| 38 | "generatedNotices": [ | |
| 39 | { | |
| 40 | "licenseId": "MIT", | |
| 41 | "copyrights": ["Copyright (c) 2026 Callstack"] | |
| 42 | } | |
| 43 | ] | |
| 44 | }, | |
| 45 | { | |
| 46 | "bundles": ["device-tools"], | |
| 47 | "includeInBundles": ["mobile", "web"], | |
| 48 | "license": "MIT AND Apache-2.0 AND BSD-3-Clause", | |
| 49 | "name": "expo-device-hub", | |
| 50 | "sourceUrl": "https://github.com/expo/expo-device-hub", | |
| 51 | "version": "0.9.0", | |
| 52 | "generatedNotices": [ | |
| 53 | { | |
| 54 | "licenseId": "MIT", | |
| 55 | "copyrights": ["Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)"] | |
| 56 | }, | |
| 57 | { | |
| 58 | "licenseId": "Apache-2.0", | |
| 59 | "preamble": [ | |
| 60 | "This distribution includes software from the original serve-sim project, created and open-sourced by Evan Bacon:\nhttps://github.com/EvanBacon/serve-sim\n\nOriginal work copyright 2026 Evan Bacon. This fork contains modifications by Expo and other contributors." | |
| 61 | ] | |
| 62 | }, | |
| 63 | { | |
| 64 | "licenseId": "BSD-3-Clause", | |
| 65 | "copyrights": ["Copyright (c) 2011, The WebRTC project authors. All rights reserved."] | |
| 66 | } | |
| 67 | ] | |
| 68 | }, | |
| 69 | { | |
| 70 | "bundles": ["android", "assets", "mobile", "web"], | |
| 71 | "license": "MIT", | |
| 72 | "name": "libghostty-vt", | |
| 73 | "noticeFile": "native/libghostty-vt/LICENSE", | |
| 74 | "sourceUrl": "https://github.com/ghostty-org/ghostty" | |
| 75 | }, | |
| 76 | { | |
| 77 | "bundles": ["assets", "web"], | |
| 78 | "license": "MIT", | |
| 79 | "name": "Symbols Nerd Font Mono", | |
| 80 | "noticeFile": "apps/web/src/terminal/ghostty/fonts/LICENSE", | |
| 81 | "sourceUrl": "https://github.com/ryanoasis/nerd-fonts" | |
| 82 | }, | |
| 83 | { | |
| 84 | "bundles": ["ios", "mobile"], | |
| 85 | "license": "MIT", | |
| 86 | "name": "GhosttyKit", | |
| 87 | "sourceUrl": "https://github.com/Yash-Singh1/ghostty/tree/t3code/custom-io-ordered-feed", | |
| 88 | "generatedNotices": [ | |
| 89 | { | |
| 90 | "licenseId": "MIT", | |
| 91 | "copyrights": ["Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors"], | |
| 92 | "preamble": [ | |
| 93 | "GhosttyKit\n\nThe iOS terminal renderer vendors GhosttyKit.xcframework, a libghostty build produced from VVTerm's custom-I/O and live-padding Ghostty branch.\n\nUpstream project: https://github.com/ghostty-org/ghostty\nBased on: https://github.com/wiedymi/ghostty/tree/vvterm/custom-io-padding\nVendored source branch: https://github.com/Yash-Singh1/ghostty/tree/t3code/custom-io-ordered-feed\nVendored revision: cf8edc23f3a6a87a96e41a90013e89e987d34980\nReference integration: https://github.com/vivy-company/vvterm" | |
| 94 | ] | |
| 95 | } | |
| 96 | ] | |
| 97 | }, | |
| 98 | { | |
| 99 | "bundles": ["android", "assets", "mobile"], | |
| 100 | "license": "Apache-2.0", | |
| 101 | "name": "MesloLGS NF", | |
| 102 | "sourceUrl": "https://github.com/romkatv/powerlevel10k-media", | |
| 103 | "generatedNotices": [ | |
| 104 | { | |
| 105 | "licenseId": "Apache-2.0", | |
| 106 | "copyrights": ["Copyright 2009, 2010, 2013 André Berg"] | |
| 107 | } | |
| 108 | ] | |
| 109 | }, | |
| 110 | { | |
| 111 | "bundles": ["mobile"], | |
| 112 | "license": "MIT", | |
| 113 | "name": "react-native-uitextview", | |
| 114 | "noticeFile": "apps/mobile/modules/t3-markdown-text/LICENSE", | |
| 115 | "sourceUrl": "https://github.com/bluesky-social/react-native-uitextview", | |
| 116 | "version": "2.2.0" | |
| 117 | }, | |
| 118 | { | |
| 119 | "bundles": ["mobile"], | |
| 120 | "license": "MIT", | |
| 121 | "name": "Expo text editor implementation", | |
| 122 | "noticeFile": "apps/mobile/modules/t3-composer-editor/LICENSE", | |
| 123 | "sourceUrl": "https://github.com/expo/expo" | |
| 124 | } | |
| 125 | ], | |
| 126 | "packageOverrides": [ | |
| 127 | { | |
| 128 | "license": "MIT", | |
| 129 | "name": "@react-grab/cli", | |
| 130 | "sourceUrl": "https://github.com/aidenybai/react-grab/tree/main/packages/cli", | |
| 131 | "generatedNotice": { | |
| 132 | "licenseId": "MIT", | |
| 133 | "copyrights": ["Copyright (c) 2025 Aiden Bai"] | |
| 134 | } | |
| 135 | }, | |
| 136 | { | |
| 137 | "name": "glob-to-regexp", | |
| 138 | "generatedNotice": { | |
| 139 | "licenseId": "BSD-2-Clause", | |
| 140 | "copyrights": ["Copyright (c) 2013, Nick Fitzgerald"] | |
| 141 | } | |
| 142 | }, | |
| 143 | { | |
| 144 | "name": "kubernetes-types", | |
| 145 | "generatedNotice": { | |
| 146 | "licenseId": "Apache-2.0" | |
| 147 | } | |
| 148 | }, | |
| 149 | { | |
| 150 | "name": "@msgpackr-extract/msgpackr-extract-linux-x64", | |
| 151 | "generatedNotice": { | |
| 152 | "licenseId": "MIT", | |
| 153 | "copyrights": ["Copyright (c) 2020 Kris Zyp"] | |
| 154 | } | |
| 155 | }, | |
| 156 | { | |
| 157 | "name": "isarray", | |
| 158 | "generatedNotice": { | |
| 159 | "licenseId": "MIT", | |
| 160 | "copyrights": ["Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>"] | |
| 161 | } | |
| 162 | }, | |
| 163 | { | |
| 164 | "name": "keyv", | |
| 165 | "generatedNotice": { | |
| 166 | "licenseId": "MIT", | |
| 167 | "copyrights": ["Copyright (c) 2017-2021 Luke Childs", "Copyright (c) 2021-2022 Jared Wray"] | |
| 168 | } | |
| 169 | }, | |
| 170 | { | |
| 171 | "license": "MIT", | |
| 172 | "name": "type-fest", | |
| 173 | "generatedNotice": { | |
| 174 | "licenseId": "MIT", | |
| 175 | "copyrights": [ | |
| 176 | "Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)" | |
| 177 | ] | |
| 178 | } | |
| 179 | }, | |
| 180 | { | |
| 181 | "name": "lazy-val", | |
| 182 | "generatedNotice": { | |
| 183 | "licenseId": "MIT", | |
| 184 | "copyrights": ["Copyright (c) Vladimir Krivosheev"] | |
| 185 | } | |
| 186 | }, | |
| 187 | { | |
| 188 | "name": "@pierre/theming", | |
| 189 | "generatedNotice": { | |
| 190 | "licenseId": "Apache-2.0", | |
| 191 | "copyrights": ["Copyright 2025 Pierre Computer Company"] | |
| 192 | } | |
| 193 | }, | |
| 194 | { | |
| 195 | "name": "lru_map", | |
| 196 | "generatedNotice": { | |
| 197 | "licenseId": "MIT", | |
| 198 | "copyrights": ["Copyright (c) 2010-2016 Rasmus Andersson <https://rsms.me/>"] | |
| 199 | } | |
| 200 | }, | |
| 201 | { | |
| 202 | "name": "@electron-internal/extract-zip", | |
| 203 | "generatedNotice": { | |
| 204 | "licenseId": "BSD-2-Clause", | |
| 205 | "copyrights": ["Copyright (c) 2026 Samuel Attard and Electron contributors"] | |
| 206 | } | |
| 207 | }, | |
| 208 | { | |
| 209 | "repositoryUrl": "https://github.com/xa11y/xa11y", | |
| 210 | "generatedNotice": { | |
| 211 | "licenseId": "MIT", | |
| 212 | "copyrights": ["Copyright (c) 2025 Stephen Crowe"] | |
| 213 | } | |
| 214 | }, | |
| 215 | { | |
| 216 | "license": "MIT", | |
| 217 | "name": "map-stream", | |
| 218 | "generatedNotice": { | |
| 219 | "licenseId": "MIT", | |
| 220 | "copyrights": ["Copyright (c) 2011 Dominic Tarr"] | |
| 221 | } | |
| 222 | }, | |
| 223 | { | |
| 224 | "license": "Apache-2.0", | |
| 225 | "name": "jsbi" | |
| 226 | }, | |
| 227 | { | |
| 228 | "name": "@yuuang/ffi-rs-android-arm64", | |
| 229 | "generatedNotice": { | |
| 230 | "licenseId": "MIT", | |
| 231 | "copyrights": ["Copyright (c) 2019 zhangyuang"] | |
| 232 | } | |
| 233 | }, | |
| 234 | { | |
| 235 | "name": "@yuuang/ffi-rs-darwin-arm64", | |
| 236 | "generatedNotice": { | |
| 237 | "licenseId": "MIT", | |
| 238 | "copyrights": ["Copyright (c) 2019 zhangyuang"] | |
| 239 | } | |
| 240 | }, | |
| 241 | { | |
| 242 | "name": "@yuuang/ffi-rs-darwin-x64", | |
| 243 | "generatedNotice": { | |
| 244 | "licenseId": "MIT", | |
| 245 | "copyrights": ["Copyright (c) 2019 zhangyuang"] | |
| 246 | } | |
| 247 | }, | |
| 248 | { | |
| 249 | "name": "@yuuang/ffi-rs-linux-arm-gnueabihf", | |
| 250 | "generatedNotice": { | |
| 251 | "licenseId": "MIT", | |
| 252 | "copyrights": ["Copyright (c) 2019 zhangyuang"] | |
| 253 | } | |
| 254 | }, | |
| 255 | { | |
| 256 | "name": "@yuuang/ffi-rs-linux-arm64-gnu", | |
| 257 | "generatedNotice": { | |
| 258 | "licenseId": "MIT", | |
| 259 | "copyrights": ["Copyright (c) 2019 zhangyuang"] | |
| 260 | } | |
| 261 | }, | |
| 262 | { | |
| 263 | "name": "@yuuang/ffi-rs-linux-arm64-musl", | |
| 264 | "generatedNotice": { | |
| 265 | "licenseId": "MIT", | |
| 266 | "copyrights": ["Copyright (c) 2019 zhangyuang"] | |
| 267 | } | |
| 268 | }, | |
| 269 | { | |
| 270 | "name": "@yuuang/ffi-rs-linux-x64-gnu", | |
| 271 | "generatedNotice": { | |
| 272 | "licenseId": "MIT", | |
| 273 | "copyrights": ["Copyright (c) 2019 zhangyuang"] | |
| 274 | } | |
| 275 | }, | |
| 276 | { | |
| 277 | "name": "@yuuang/ffi-rs-linux-x64-musl", | |
| 278 | "generatedNotice": { | |
| 279 | "licenseId": "MIT", | |
| 280 | "copyrights": ["Copyright (c) 2019 zhangyuang"] | |
| 281 | } | |
| 282 | }, | |
| 283 | { | |
| 284 | "name": "@yuuang/ffi-rs-win32-arm64-msvc", | |
| 285 | "generatedNotice": { | |
| 286 | "licenseId": "MIT", | |
| 287 | "copyrights": ["Copyright (c) 2019 zhangyuang"] | |
| 288 | } | |
| 289 | }, | |
| 290 | { | |
| 291 | "name": "@yuuang/ffi-rs-win32-ia32-msvc", | |
| 292 | "generatedNotice": { | |
| 293 | "licenseId": "MIT", | |
| 294 | "copyrights": ["Copyright (c) 2019 zhangyuang"] | |
| 295 | } | |
| 296 | }, | |
| 297 | { | |
| 298 | "name": "@yuuang/ffi-rs-win32-x64-msvc", | |
| 299 | "generatedNotice": { | |
| 300 | "licenseId": "MIT", | |
| 301 | "copyrights": ["Copyright (c) 2019 zhangyuang"] | |
| 302 | } | |
| 303 | }, | |
| 304 | { | |
| 305 | "repositoryUrl": "https://github.com/facebook/metro", | |
| 306 | "generatedNotice": { | |
| 307 | "licenseId": "MIT", | |
| 308 | "copyrights": ["Copyright (c) Meta Platforms, Inc. and affiliates."] | |
| 309 | } | |
| 310 | }, | |
| 311 | { | |
| 312 | "repositoryUrl": "https://github.com/react/metro", | |
| 313 | "generatedNotice": { | |
| 314 | "licenseId": "MIT", | |
| 315 | "copyrights": ["Copyright (c) Meta Platforms, Inc. and affiliates."] | |
| 316 | } | |
| 317 | }, | |
| 318 | { | |
| 319 | "name": "@react-native-ai/apple", | |
| 320 | "sourceUrl": "https://github.com/callstackincubator/ai", | |
| 321 | "generatedNotice": { | |
| 322 | "licenseId": "MIT", | |
| 323 | "copyrights": [ | |
| 324 | "Copyright (c) 2024-2025 Szymon Rybczak", | |
| 325 | "Copyright (c) 2025-present Callstack" | |
| 326 | ] | |
| 327 | } | |
| 328 | }, | |
| 329 | { | |
| 330 | "name": "react-remove-scroll-bar", | |
| 331 | "generatedNotice": { | |
| 332 | "licenseId": "MIT", | |
| 333 | "copyrights": ["Copyright (c) Anton Korzunov <thekashey@gmail.com>"] | |
| 334 | } | |
| 335 | }, | |
| 336 | { | |
| 337 | "name": "standard-navigation", | |
| 338 | "generatedNotice": { | |
| 339 | "licenseId": "MIT", | |
| 340 | "copyrights": ["Copyright (c) 2026 React Navigation Contributors"] | |
| 341 | } | |
| 342 | }, | |
| 343 | { | |
| 344 | "name": "@expo/sdk-runtime-versions", | |
| 345 | "noticeFile": "apps/mobile/modules/t3-composer-editor/LICENSE" | |
| 346 | }, | |
| 347 | { | |
| 348 | "name": "@expo/ws-tunnel", | |
| 349 | "noticeFile": "apps/mobile/modules/t3-composer-editor/LICENSE" | |
| 350 | }, | |
| 351 | { | |
| 352 | "name": "stream-buffers", | |
| 353 | "generatedNotice": { | |
| 354 | "licenseId": "Unlicense" | |
| 355 | } | |
| 356 | }, | |
| 357 | { | |
| 358 | "name": "bplist-parser", | |
| 359 | "generatedNotice": { | |
| 360 | "licenseId": "MIT", | |
| 361 | "copyrights": ["Copyright (c) 2012 Near Infinity Corporation"] | |
| 362 | } | |
| 363 | }, | |
| 364 | { | |
| 365 | "name": "@expo/devcert", | |
| 366 | "generatedNotice": { | |
| 367 | "licenseId": "MIT", | |
| 368 | "copyrights": ["Copyright (c) Dave Wasmer"] | |
| 369 | } | |
| 370 | }, | |
| 371 | { | |
| 372 | "name": "jimp-compact", | |
| 373 | "generatedNotice": { | |
| 374 | "licenseId": "MIT", | |
| 375 | "copyrights": ["Copyright (c) 2018 Oliver Moran"] | |
| 376 | } | |
| 377 | }, | |
| 378 | { | |
| 379 | "name": "fb-watchman", | |
| 380 | "generatedNotice": { | |
| 381 | "licenseId": "Apache-2.0" | |
| 382 | } | |
| 383 | }, | |
| 384 | { | |
| 385 | "name": "bser", | |
| 386 | "generatedNotice": { | |
| 387 | "licenseId": "Apache-2.0" | |
| 388 | } | |
| 389 | }, | |
| 390 | { | |
| 391 | "name": "@expo/xcpretty", | |
| 392 | "generatedNotice": { | |
| 393 | "licenseId": "BSD-3-Clause", | |
| 394 | "copyrights": ["Copyright (c) 2016-present, 650 Industries, Inc. (Expo)"] | |
| 395 | } | |
| 396 | }, | |
| 397 | { | |
| 398 | "license": "MIT", | |
| 399 | "name": "fb-dotslash", | |
| 400 | "generatedNotice": { | |
| 401 | "licenseId": "MIT", | |
| 402 | "copyrights": ["Copyright (c) Meta Platforms, Inc. and affiliates."] | |
| 403 | } | |
| 404 | }, | |
| 405 | { | |
| 406 | "name": "structured-headers", | |
| 407 | "generatedNotice": { | |
| 408 | "licenseId": "MIT", | |
| 409 | "copyrights": ["Copyright (c) 2018 Evert Pot"] | |
| 410 | } | |
| 411 | }, | |
| 412 | { | |
| 413 | "name": "badgin", | |
| 414 | "generatedNotice": { | |
| 415 | "licenseId": "MIT", | |
| 416 | "copyrights": ["Copyright (c) Julian Hundeloh and Tinycon contributors"], | |
| 417 | "preamble": ["badgin is a refactored fork of Tinycon."] | |
| 418 | } | |
| 419 | }, | |
| 420 | { | |
| 421 | "name": "react-native-nitro-modules", | |
| 422 | "generatedNotice": { | |
| 423 | "licenseId": "MIT", | |
| 424 | "copyrights": ["Copyright (c) 2024 Marc Rousavy"] | |
| 425 | } | |
| 426 | }, | |
| 427 | { | |
| 428 | "name": "boolbase", | |
| 429 | "generatedNotice": { | |
| 430 | "licenseId": "ISC", | |
| 431 | "copyrights": ["Copyright (c) 2014-2015, Felix Boehm <me@feedic.com>"] | |
| 432 | } | |
| 433 | }, | |
| 434 | { | |
| 435 | "repositoryUrl": "https://github.com/dmtrKovalenko/fff", | |
| 436 | "generatedNotice": { | |
| 437 | "licenseId": "MIT", | |
| 438 | "copyrights": ["Copyright (c) 2025 Dmitriy Kovalenko"] | |
| 439 | } | |
| 440 | }, | |
| 441 | { | |
| 442 | "name": "@opencode-ai/sdk", | |
| 443 | "sourceUrl": "https://github.com/anomalyco/opencode/tree/dev/packages/sdk/js", | |
| 444 | "generatedNotice": { | |
| 445 | "licenseId": "MIT", | |
| 446 | "copyrights": ["Copyright (c) 2025 opencode"] | |
| 447 | } | |
| 448 | } | |
| 449 | ] | |
| 450 | } |