| 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,6 +30,8 @@ dist-electron/ |
| 30 | .electron-runtime/ | 30 | .electron-runtime/ |
| 31 | .showcase/ | 31 | .showcase/ |
| 32 | apps/mobile/.showcase/ | 32 | apps/mobile/.showcase/ |
| 33 | apps/mobile/.generated/ | ||
| 34 | /.generated/ | ||
| 33 | artifacts/app-store/screenshots/ | 35 | artifacts/app-store/screenshots/ |
| 34 | .github/pr-assets/ | 36 | .github/pr-assets/ |
| 35 | native/**/target/ | 37 | native/**/target/ |
apps/mobile/metro.config.js+52-5| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | const fs = require("node:fs"); | 1 | const fs = require("node:fs"); |
| 2 | const path = require("node:path"); | 2 | const path = require("node:path"); |
| 3 | const { pathToFileURL } = require("node:url"); | ||
| 3 | const { getDefaultConfig } = require("expo/metro-config"); | 4 | const { getDefaultConfig } = require("expo/metro-config"); |
| 4 | const { withUniwindConfig } = require("uniwind/metro"); | 5 | const { withUniwindConfig } = require("uniwind/metro"); |
| 5 | const extraThemes = require("./generated-uniwind-theme-names.json"); | 6 | const extraThemes = require("./generated-uniwind-theme-names.json"); |
| ... | @@ -7,6 +8,13 @@ const extraThemes = require("./generated-uniwind-theme-names.json"); | ... | @@ -7,6 +8,13 @@ const extraThemes = require("./generated-uniwind-theme-names.json"); |
| 7 | /** @type {import("expo/metro-config").MetroConfig} */ | 8 | /** @type {import("expo/metro-config").MetroConfig} */ |
| 8 | const config = getDefaultConfig(__dirname); | 9 | const config = getDefaultConfig(__dirname); |
| 9 | const workspaceRoot = path.resolve(__dirname, "../.."); | 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 | const escapedWorkspaceRoot = workspaceRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | 18 | const escapedWorkspaceRoot = workspaceRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); |
| 11 | const mobileShikiRoot = path.dirname(require.resolve("shiki/package.json", { paths: [__dirname] })); | 19 | const mobileShikiRoot = path.dirname(require.resolve("shiki/package.json", { paths: [__dirname] })); |
| 12 | const resolveShikiDependencyRoot = (packageName) => { | 20 | const resolveShikiDependencyRoot = (packageName) => { |
| ... | @@ -37,6 +45,7 @@ config.resolver = { | ... | @@ -37,6 +45,7 @@ config.resolver = { |
| 37 | ], | 45 | ], |
| 38 | extraNodeModules: { | 46 | extraNodeModules: { |
| 39 | ...config.resolver?.extraNodeModules, | 47 | ...config.resolver?.extraNodeModules, |
| 48 | "@t3tools/mobile-third-party-licenses": generatedLicenseModuleRoot, | ||
| 40 | shiki: mobileShikiRoot, | 49 | shiki: mobileShikiRoot, |
| 41 | "@shikijs/core": resolveShikiDependencyRoot("@shikijs/core"), | 50 | "@shikijs/core": resolveShikiDependencyRoot("@shikijs/core"), |
| 42 | "@shikijs/engine-javascript": resolveShikiDependencyRoot("@shikijs/engine-javascript"), | 51 | "@shikijs/engine-javascript": resolveShikiDependencyRoot("@shikijs/engine-javascript"), |
| ... | @@ -48,8 +57,46 @@ config.resolver = { | ... | @@ -48,8 +57,46 @@ config.resolver = { |
| 48 | }, | 57 | }, |
| 49 | }; | 58 | }; |
| 50 | 59 | ||
| 51 | module.exports = withUniwindConfig(config, { | 60 | async function writeFileIfChanged(filePath, contents) { |
| 52 | cssEntryFile: "./global.css", | 61 | try { |
| 53 | extraThemes, | 62 | if ((await fs.promises.readFile(filePath, "utf8")) === contents) return; |
| 54 | polyfills: { rem: 14 }, | 63 | } catch (error) { |
| 55 | }); | 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,11 +79,7 @@ const colors = { |
| 79 | 79 | ||
| 80 | const customIcons = { | 80 | const customIcons = { |
| 81 | agents: "t3-file-icon-agents", | 81 | agents: "t3-file-icon-agents", |
| 82 | claude: "t3-file-icon-claude", | ||
| 83 | package: "t3-file-icon-package-json", | ||
| 84 | pnpm: "t3-file-icon-pnpm", | 82 | pnpm: "t3-file-icon-pnpm", |
| 85 | readme: "t3-file-icon-readme", | ||
| 86 | tsconfig: "t3-file-icon-tsconfig", | ||
| 87 | video: "t3-file-icon-video", | 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,13 +30,11 @@ export const MARKDOWN_FILE_ICON_SOURCES = { |
| 30 | nextjs: require("../assets/file-icons/pierre_nextjs.png"), | 30 | nextjs: require("../assets/file-icons/pierre_nextjs.png"), |
| 31 | npm: require("../assets/file-icons/pierre_npm.png"), | 31 | npm: require("../assets/file-icons/pierre_npm.png"), |
| 32 | oxc: require("../assets/file-icons/pierre_oxc.png"), | 32 | oxc: require("../assets/file-icons/pierre_oxc.png"), |
| 33 | package: require("../assets/file-icons/pierre_package.png"), | ||
| 34 | pnpm: require("../assets/file-icons/pierre_pnpm.png"), | 33 | pnpm: require("../assets/file-icons/pierre_pnpm.png"), |
| 35 | postcss: require("../assets/file-icons/pierre_postcss.png"), | 34 | postcss: require("../assets/file-icons/pierre_postcss.png"), |
| 36 | prettier: require("../assets/file-icons/pierre_prettier.png"), | 35 | prettier: require("../assets/file-icons/pierre_prettier.png"), |
| 37 | python: require("../assets/file-icons/pierre_python.png"), | 36 | python: require("../assets/file-icons/pierre_python.png"), |
| 38 | react: require("../assets/file-icons/pierre_react.png"), | 37 | react: require("../assets/file-icons/pierre_react.png"), |
| 39 | readme: require("../assets/file-icons/pierre_readme.png"), | ||
| 40 | ruby: require("../assets/file-icons/pierre_ruby.png"), | 38 | ruby: require("../assets/file-icons/pierre_ruby.png"), |
| 41 | rust: require("../assets/file-icons/pierre_rust.png"), | 39 | rust: require("../assets/file-icons/pierre_rust.png"), |
| 42 | sass: require("../assets/file-icons/pierre_sass.png"), | 40 | sass: require("../assets/file-icons/pierre_sass.png"), |
| ... | @@ -49,7 +47,6 @@ export const MARKDOWN_FILE_ICON_SOURCES = { | ... | @@ -49,7 +47,6 @@ export const MARKDOWN_FILE_ICON_SOURCES = { |
| 49 | tailwind: require("../assets/file-icons/pierre_tailwind.png"), | 47 | tailwind: require("../assets/file-icons/pierre_tailwind.png"), |
| 50 | terraform: require("../assets/file-icons/pierre_terraform.png"), | 48 | terraform: require("../assets/file-icons/pierre_terraform.png"), |
| 51 | text: require("../assets/file-icons/pierre_text.png"), | 49 | text: require("../assets/file-icons/pierre_text.png"), |
| 52 | tsconfig: require("../assets/file-icons/pierre_tsconfig.png"), | ||
| 53 | typescript: require("../assets/file-icons/pierre_typescript.png"), | 50 | typescript: require("../assets/file-icons/pierre_typescript.png"), |
| 54 | video: require("../assets/file-icons/pierre_video.png"), | 51 | video: require("../assets/file-icons/pierre_video.png"), |
| 55 | vite: require("../assets/file-icons/pierre_vite.png"), | 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,7 +116,7 @@ const FILE_ICON_BY_NAME: Readonly<Record<string, MarkdownFileIcon>> = { |
| 116 | "next.config.mjs": "nextjs", | 116 | "next.config.mjs": "nextjs", |
| 117 | "next.config.mts": "nextjs", | 117 | "next.config.mts": "nextjs", |
| 118 | "next.config.ts": "nextjs", | 118 | "next.config.ts": "nextjs", |
| 119 | "package.json": "package", | 119 | "package.json": "npm", |
| 120 | "pnpm-lock.yaml": "pnpm", | 120 | "pnpm-lock.yaml": "pnpm", |
| 121 | "pnpm-workspace.yaml": "pnpm", | 121 | "pnpm-workspace.yaml": "pnpm", |
| 122 | "postcss.config.js": "postcss", | 122 | "postcss.config.js": "postcss", |
| ... | @@ -127,7 +127,7 @@ const FILE_ICON_BY_NAME: Readonly<Record<string, MarkdownFileIcon>> = { | ... | @@ -127,7 +127,7 @@ const FILE_ICON_BY_NAME: Readonly<Record<string, MarkdownFileIcon>> = { |
| 127 | "prettier.config.cjs": "prettier", | 127 | "prettier.config.cjs": "prettier", |
| 128 | "prettier.config.mjs": "prettier", | 128 | "prettier.config.mjs": "prettier", |
| 129 | rakefile: "ruby", | 129 | rakefile: "ruby", |
| 130 | "readme.md": "readme", | 130 | "readme.md": "markdown", |
| 131 | "stylelint.config.js": "stylelint", | 131 | "stylelint.config.js": "stylelint", |
| 132 | "stylelint.config.cjs": "stylelint", | 132 | "stylelint.config.cjs": "stylelint", |
| 133 | "stylelint.config.mjs": "stylelint", | 133 | "stylelint.config.mjs": "stylelint", |
| ... | @@ -139,7 +139,7 @@ const FILE_ICON_BY_NAME: Readonly<Record<string, MarkdownFileIcon>> = { | ... | @@ -139,7 +139,7 @@ const FILE_ICON_BY_NAME: Readonly<Record<string, MarkdownFileIcon>> = { |
| 139 | "tailwind.config.cjs": "tailwind", | 139 | "tailwind.config.cjs": "tailwind", |
| 140 | "tailwind.config.mjs": "tailwind", | 140 | "tailwind.config.mjs": "tailwind", |
| 141 | "tailwind.config.ts": "tailwind", | 141 | "tailwind.config.ts": "tailwind", |
| 142 | "tsconfig.json": "tsconfig", | 142 | "tsconfig.json": "typescript", |
| 143 | "vite.config.js": "vite", | 143 | "vite.config.js": "vite", |
| 144 | "vite.config.mjs": "vite", | 144 | "vite.config.mjs": "vite", |
| 145 | "vite.config.mts": "vite", | 145 | "vite.config.mts": "vite", |
| ... | @@ -253,7 +253,7 @@ export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { | ... | @@ -253,7 +253,7 @@ export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { |
| 253 | const exactIcon = FILE_ICON_BY_NAME[basename]; | 253 | const exactIcon = FILE_ICON_BY_NAME[basename]; |
| 254 | if (exactIcon) return exactIcon; | 254 | if (exactIcon) return exactIcon; |
| 255 | if (basename.startsWith("tsconfig.") && basename.endsWith(".json")) { | 255 | if (basename.startsWith("tsconfig.") && basename.endsWith(".json")) { |
| 256 | return "tsconfig"; | 256 | return "typescript"; |
| 257 | } | 257 | } |
| 258 | const segments = basename.split("."); | 258 | const segments = basename.split("."); |
| 259 | for (let index = 1; index < segments.length; index += 1) { | 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,6 +55,10 @@ import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsCl |
| 55 | import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; | 55 | import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; |
| 56 | import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; | 56 | import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; |
| 57 | import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; | 57 | import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; |
| 58 | import { | ||
| 59 | SettingsOpenSourceLicenseRouteScreen, | ||
| 60 | SettingsOpenSourceLicensesRouteScreen, | ||
| 61 | } from "./features/settings/SettingsOpenSourceLicensesRouteScreen"; | ||
| 58 | import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen"; | 62 | import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen"; |
| 59 | import { UsageLimitAccountScreen } from "./features/usage/UsageLimitsPooled"; | 63 | import { UsageLimitAccountScreen } from "./features/usage/UsageLimitsPooled"; |
| 60 | import { UsageRouteScreen } from "./features/usage/UsageRouteScreen"; | 64 | import { UsageRouteScreen } from "./features/usage/UsageRouteScreen"; |
| ... | @@ -197,6 +201,20 @@ const SettingsContentStack = createNativeStackNavigator({ | ... | @@ -197,6 +201,20 @@ const SettingsContentStack = createNativeStackNavigator({ |
| 197 | screen: UsageLimitAccountScreen, | 201 | screen: UsageLimitAccountScreen, |
| 198 | options: { title: "Account" }, | 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 | SettingsUsage: createNativeStackScreen({ | 218 | SettingsUsage: createNativeStackScreen({ |
| 201 | screen: UsageRouteScreen, | 219 | screen: UsageRouteScreen, |
| 202 | linking: "usage", | 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,6 +821,11 @@ function AppSettingsSection() { |
| 821 | return ( | 821 | return ( |
| 822 | <SettingsSection title="App"> | 822 | <SettingsSection title="App"> |
| 823 | <SettingsRow icon="internaldrive" label="Client Storage" target="SettingsClientStorage" /> | 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 | <SettingsRow icon="doc.text" label="Legal" fullScreenTarget="SettingsLegal" /> | 829 | <SettingsRow icon="doc.text" label="Legal" fullScreenTarget="SettingsLegal" /> |
| 825 | {updateCheckAvailable ? ( | 830 | {updateCheckAvailable ? ( |
| 826 | <Pressable | 831 | <Pressable |
apps/mobile/src/features/settings/components/settings-sheet-targets.ts+1| ... | @@ -4,6 +4,7 @@ export type SettingsSheetTarget = | ... | @@ -4,6 +4,7 @@ export type SettingsSheetTarget = |
| 4 | | "SettingsAppearance" | 4 | | "SettingsAppearance" |
| 5 | | "SettingsProjectGrouping" | 5 | | "SettingsProjectGrouping" |
| 6 | | "SettingsClientStorage" | 6 | | "SettingsClientStorage" |
| 7 | | "SettingsOpenSourceLicenses" | ||
| 7 | | "SettingsUsage"; | 8 | | "SettingsUsage"; |
| 8 | 9 | ||
| 9 | export type SettingsLegalDocumentTarget = "SettingsLegal"; | 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,7 +75,7 @@ describe("resolveMarkdownLinkPresentation", () => { |
| 75 | expect(resolveMarkdownLinkPresentation("package.json")).toEqual({ | 75 | expect(resolveMarkdownLinkPresentation("package.json")).toEqual({ |
| 76 | kind: "file", | 76 | kind: "file", |
| 77 | href: "package.json", | 77 | href: "package.json", |
| 78 | icon: "package", | 78 | icon: "npm", |
| 79 | label: "package.json", | 79 | label: "package.json", |
| 80 | path: "package.json", | 80 | path: "package.json", |
| 81 | }); | 81 | }); |
apps/mobile/src/lib/nativeMarkdownText.test.ts+1-1| ... | @@ -95,7 +95,7 @@ describe("nativeMarkdownTextRuns", () => { | ... | @@ -95,7 +95,7 @@ describe("nativeMarkdownTextRuns", () => { |
| 95 | { | 95 | { |
| 96 | text: "README.md:12", | 96 | text: "README.md:12", |
| 97 | href: "file:///repo/README.md#L12", | 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,6 +36,7 @@ import { |
| 36 | const SETTINGS_BREADCRUMB_LABELS: Readonly<Record<string, string>> = { | 36 | const SETTINGS_BREADCRUMB_LABELS: Readonly<Record<string, string>> = { |
| 37 | ...SETTINGS_SECTION_LABELS, | 37 | ...SETTINGS_SECTION_LABELS, |
| 38 | "/settings/diagnostics": "Diagnostics", | 38 | "/settings/diagnostics": "Diagnostics", |
| 39 | "/settings/open-source-licenses": "Open source licenses", | ||
| 39 | }; | 40 | }; |
| 40 | 41 | ||
| 41 | function settingsBreadcrumbLabel(pathname: string): string | null { | 42 | function settingsBreadcrumbLabel(pathname: string): string | null { |
apps/web/src/components/settings/SettingsPanels.tsx+13| ... | @@ -2907,6 +2907,19 @@ export function GeneralSettingsPanel() { | ... | @@ -2907,6 +2907,19 @@ export function GeneralSettingsPanel() { |
| 2907 | </Button> | 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 | </SettingsSection> | 2923 | </SettingsSection> |
| 2911 | 2924 | ||
| 2912 | <LegacyFeaturesSection /> | 2925 | <LegacyFeaturesSection /> |
apps/web/src/components/settings/SettingsSidebarNav.tsx+4-1| ... | @@ -322,7 +322,10 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { | ... | @@ -322,7 +322,10 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { |
| 322 | <SidebarMenu className="ps-px"> | 322 | <SidebarMenu className="ps-px"> |
| 323 | {navItems.map((item) => { | 323 | {navItems.map((item) => { |
| 324 | const Icon = item.icon; | 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 | return ( | 329 | return ( |
| 327 | <SidebarMenuItem key={item.to}> | 330 | <SidebarMenuItem key={item.to}> |
| 328 | <SidebarMenuButton | 331 | <SidebarMenuButton |
apps/web/src/components/settings/settingsSearch.ts+5| ... | @@ -355,6 +355,11 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -355,6 +355,11 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 355 | to: "/settings/general", | 355 | to: "/settings/general", |
| 356 | searchTerms: ["logs traces processes resource history failures spans cpu memory"], | 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 | id: "legacy-plan-mode", | 364 | id: "legacy-plan-mode", |
| 360 | title: "Plan mode (legacy)", | 365 | title: "Plan mode (legacy)", |
apps/web/src/pierre-icons.test.ts+13-8| ... | @@ -14,18 +14,21 @@ describe("Pierre file icons", () => { | ... | @@ -14,18 +14,21 @@ describe("Pierre file icons", () => { |
| 14 | assert.equal(resolvePierreIconForEntry("vite.config.ts", "file")?.token, "vite"); | 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 | assert.equal( | 19 | assert.equal( |
| 19 | resolvePierreIconForEntry("package.json", "file")?.name, | 20 | resolvePierreIconForEntry("config/tsconfig.json", "file")?.name, |
| 20 | "t3-file-icon-package-json", | 21 | "file-tree-builtin-typescript", |
| 21 | ); | 22 | ); |
| 23 | assert.equal(resolvePierreIconForEntry("CLAUDE.md", "file")?.name, "file-tree-builtin-claude"); | ||
| 22 | assert.equal( | 24 | assert.equal( |
| 23 | resolvePierreIconForEntry("config/tsconfig.json", "file")?.name, | 25 | resolvePierreIconForEntry("README.md", "file")?.name, |
| 24 | "t3-file-icon-tsconfig", | 26 | "file-tree-builtin-markdown", |
| 25 | ); | 27 | ); |
| 28 | }); | ||
| 29 | |||
| 30 | it("extends Pierre with T3-specific exact filename icons", () => { | ||
| 26 | assert.equal(resolvePierreIconForEntry("AGENTS.md", "file")?.name, "t3-file-icon-agents"); | 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 | assert.equal(resolvePierreIconForEntry("pnpm-lock.yaml", "file")?.name, "t3-file-icon-pnpm"); | 32 | assert.equal(resolvePierreIconForEntry("pnpm-lock.yaml", "file")?.name, "t3-file-icon-pnpm"); |
| 30 | assert.equal( | 33 | assert.equal( |
| 31 | resolvePierreIconForEntry("pnpm-workspace.yaml", "file")?.name, | 34 | resolvePierreIconForEntry("pnpm-workspace.yaml", "file")?.name, |
| ... | @@ -34,7 +37,9 @@ describe("Pierre file icons", () => { | ... | @@ -34,7 +37,9 @@ describe("Pierre file icons", () => { |
| 34 | }); | 37 | }); |
| 35 | 38 | ||
| 36 | it("ships every custom icon referenced by the extended resolver", () => { | 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 | for (const iconName of customIconNames) { | 43 | for (const iconName of customIconNames) { |
| 39 | assert.include(T3_PIERRE_ICONS.spriteSheet, `id="${iconName}"`); | 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,24 +21,9 @@ const T3_FILE_ICON_SPRITE = ` |
| 21 | <path d="M7 3v18M3 7.5h4M3 12h18M3 16.5h4M17 3v18M17 7.5h4M17 16.5h4" /> | 21 | <path d="M7 3v18M3 7.5h4M3 12h18M3 16.5h4M17 3v18M17 7.5h4M17 16.5h4" /> |
| 22 | </g> | 22 | </g> |
| 23 | </symbol> | 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 | <symbol id="t3-file-icon-agents" viewBox="0 0 32 32"> | 24 | <symbol id="t3-file-icon-agents" viewBox="0 0 32 32"> |
| 33 | <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" /> | 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 | </symbol> | 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 | <symbol id="t3-file-icon-pnpm" viewBox="0 0 32 32"> | 27 | <symbol id="t3-file-icon-pnpm" viewBox="0 0 32 32"> |
| 43 | <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" /> | 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 | <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" /> | 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,11 +35,9 @@ export const T3_PIERRE_ICONS = { |
| 50 | colored: true, | 35 | colored: true, |
| 51 | spriteSheet: T3_FILE_ICON_SPRITE, | 36 | spriteSheet: T3_FILE_ICON_SPRITE, |
| 52 | byFileName: { | 37 | byFileName: { |
| 53 | "package.json": "t3-file-icon-package-json", | 38 | "package.json": "file-tree-builtin-npm", |
| 54 | "tsconfig.json": "t3-file-icon-tsconfig", | 39 | "tsconfig.json": "file-tree-builtin-typescript", |
| 55 | "agents.md": "t3-file-icon-agents", | 40 | "agents.md": "t3-file-icon-agents", |
| 56 | "claude.md": "t3-file-icon-claude", | ||
| 57 | "readme.md": "t3-file-icon-readme", | ||
| 58 | "pnpm-lock.yaml": "t3-file-icon-pnpm", | 41 | "pnpm-lock.yaml": "t3-file-icon-pnpm", |
| 59 | "pnpm-workspace.yaml": "t3-file-icon-pnpm", | 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,6 +20,7 @@ import { Route as SettingsSourceControlRouteImport } from './routes/settings.sou |
| 20 | import { Route as SettingsSnapShotRouteImport } from './routes/settings.snap-shot' | 20 | import { Route as SettingsSnapShotRouteImport } from './routes/settings.snap-shot' |
| 21 | import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' | 21 | import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' |
| 22 | import { Route as SettingsProjectsRouteImport } from './routes/settings.projects' | 22 | import { Route as SettingsProjectsRouteImport } from './routes/settings.projects' |
| 23 | import { Route as SettingsOpenSourceLicensesRouteImport } from './routes/settings.open-source-licenses' | ||
| 23 | import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' | 24 | import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' |
| 24 | import { Route as SettingsIntegrationsRouteImport } from './routes/settings.integrations' | 25 | import { Route as SettingsIntegrationsRouteImport } from './routes/settings.integrations' |
| 25 | import { Route as SettingsGeneralRouteImport } from './routes/settings.general' | 26 | import { Route as SettingsGeneralRouteImport } from './routes/settings.general' |
| ... | @@ -87,6 +88,12 @@ const SettingsProjectsRoute = SettingsProjectsRouteImport.update({ | ... | @@ -87,6 +88,12 @@ const SettingsProjectsRoute = SettingsProjectsRouteImport.update({ |
| 87 | path: '/projects', | 88 | path: '/projects', |
| 88 | getParentRoute: () => SettingsRoute, | 89 | getParentRoute: () => SettingsRoute, |
| 89 | } as any) | 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 | const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({ | 97 | const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({ |
| 91 | id: '/keybindings', | 98 | id: '/keybindings', |
| 92 | path: '/keybindings', | 99 | path: '/keybindings', |
| ... | @@ -166,6 +173,7 @@ export interface FileRoutesByFullPath { | ... | @@ -166,6 +173,7 @@ export interface FileRoutesByFullPath { |
| 166 | '/settings/general': typeof SettingsGeneralRoute | 173 | '/settings/general': typeof SettingsGeneralRoute |
| 167 | '/settings/integrations': typeof SettingsIntegrationsRoute | 174 | '/settings/integrations': typeof SettingsIntegrationsRoute |
| 168 | '/settings/keybindings': typeof SettingsKeybindingsRoute | 175 | '/settings/keybindings': typeof SettingsKeybindingsRoute |
| 176 | '/settings/open-source-licenses': typeof SettingsOpenSourceLicensesRoute | ||
| 169 | '/settings/projects': typeof SettingsProjectsRoute | 177 | '/settings/projects': typeof SettingsProjectsRoute |
| 170 | '/settings/providers': typeof SettingsProvidersRoute | 178 | '/settings/providers': typeof SettingsProvidersRoute |
| 171 | '/settings/snap-shot': typeof SettingsSnapShotRoute | 179 | '/settings/snap-shot': typeof SettingsSnapShotRoute |
| ... | @@ -189,6 +197,7 @@ export interface FileRoutesByTo { | ... | @@ -189,6 +197,7 @@ export interface FileRoutesByTo { |
| 189 | '/settings/general': typeof SettingsGeneralRoute | 197 | '/settings/general': typeof SettingsGeneralRoute |
| 190 | '/settings/integrations': typeof SettingsIntegrationsRoute | 198 | '/settings/integrations': typeof SettingsIntegrationsRoute |
| 191 | '/settings/keybindings': typeof SettingsKeybindingsRoute | 199 | '/settings/keybindings': typeof SettingsKeybindingsRoute |
| 200 | '/settings/open-source-licenses': typeof SettingsOpenSourceLicensesRoute | ||
| 192 | '/settings/projects': typeof SettingsProjectsRoute | 201 | '/settings/projects': typeof SettingsProjectsRoute |
| 193 | '/settings/providers': typeof SettingsProvidersRoute | 202 | '/settings/providers': typeof SettingsProvidersRoute |
| 194 | '/settings/snap-shot': typeof SettingsSnapShotRoute | 203 | '/settings/snap-shot': typeof SettingsSnapShotRoute |
| ... | @@ -215,6 +224,7 @@ export interface FileRoutesById { | ... | @@ -215,6 +224,7 @@ export interface FileRoutesById { |
| 215 | '/settings/general': typeof SettingsGeneralRoute | 224 | '/settings/general': typeof SettingsGeneralRoute |
| 216 | '/settings/integrations': typeof SettingsIntegrationsRoute | 225 | '/settings/integrations': typeof SettingsIntegrationsRoute |
| 217 | '/settings/keybindings': typeof SettingsKeybindingsRoute | 226 | '/settings/keybindings': typeof SettingsKeybindingsRoute |
| 227 | '/settings/open-source-licenses': typeof SettingsOpenSourceLicensesRoute | ||
| 218 | '/settings/projects': typeof SettingsProjectsRoute | 228 | '/settings/projects': typeof SettingsProjectsRoute |
| 219 | '/settings/providers': typeof SettingsProvidersRoute | 229 | '/settings/providers': typeof SettingsProvidersRoute |
| 220 | '/settings/snap-shot': typeof SettingsSnapShotRoute | 230 | '/settings/snap-shot': typeof SettingsSnapShotRoute |
| ... | @@ -242,6 +252,7 @@ export interface FileRouteTypes { | ... | @@ -242,6 +252,7 @@ export interface FileRouteTypes { |
| 242 | | '/settings/general' | 252 | | '/settings/general' |
| 243 | | '/settings/integrations' | 253 | | '/settings/integrations' |
| 244 | | '/settings/keybindings' | 254 | | '/settings/keybindings' |
| 255 | | '/settings/open-source-licenses' | ||
| 245 | | '/settings/projects' | 256 | | '/settings/projects' |
| 246 | | '/settings/providers' | 257 | | '/settings/providers' |
| 247 | | '/settings/snap-shot' | 258 | | '/settings/snap-shot' |
| ... | @@ -265,6 +276,7 @@ export interface FileRouteTypes { | ... | @@ -265,6 +276,7 @@ export interface FileRouteTypes { |
| 265 | | '/settings/general' | 276 | | '/settings/general' |
| 266 | | '/settings/integrations' | 277 | | '/settings/integrations' |
| 267 | | '/settings/keybindings' | 278 | | '/settings/keybindings' |
| 279 | | '/settings/open-source-licenses' | ||
| 268 | | '/settings/projects' | 280 | | '/settings/projects' |
| 269 | | '/settings/providers' | 281 | | '/settings/providers' |
| 270 | | '/settings/snap-shot' | 282 | | '/settings/snap-shot' |
| ... | @@ -290,6 +302,7 @@ export interface FileRouteTypes { | ... | @@ -290,6 +302,7 @@ export interface FileRouteTypes { |
| 290 | | '/settings/general' | 302 | | '/settings/general' |
| 291 | | '/settings/integrations' | 303 | | '/settings/integrations' |
| 292 | | '/settings/keybindings' | 304 | | '/settings/keybindings' |
| 305 | | '/settings/open-source-licenses' | ||
| 293 | | '/settings/projects' | 306 | | '/settings/projects' |
| 294 | | '/settings/providers' | 307 | | '/settings/providers' |
| 295 | | '/settings/snap-shot' | 308 | | '/settings/snap-shot' |
| ... | @@ -389,6 +402,13 @@ declare module '@tanstack/react-router' { | ... | @@ -389,6 +402,13 @@ declare module '@tanstack/react-router' { |
| 389 | preLoaderRoute: typeof SettingsProjectsRouteImport | 402 | preLoaderRoute: typeof SettingsProjectsRouteImport |
| 390 | parentRoute: typeof SettingsRoute | 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 | '/settings/keybindings': { | 412 | '/settings/keybindings': { |
| 393 | id: '/settings/keybindings' | 413 | id: '/settings/keybindings' |
| 394 | path: '/keybindings' | 414 | path: '/keybindings' |
| ... | @@ -500,6 +520,7 @@ interface SettingsRouteChildren { | ... | @@ -500,6 +520,7 @@ interface SettingsRouteChildren { |
| 500 | SettingsGeneralRoute: typeof SettingsGeneralRoute | 520 | SettingsGeneralRoute: typeof SettingsGeneralRoute |
| 501 | SettingsIntegrationsRoute: typeof SettingsIntegrationsRoute | 521 | SettingsIntegrationsRoute: typeof SettingsIntegrationsRoute |
| 502 | SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute | 522 | SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute |
| 523 | SettingsOpenSourceLicensesRoute: typeof SettingsOpenSourceLicensesRoute | ||
| 503 | SettingsProjectsRoute: typeof SettingsProjectsRoute | 524 | SettingsProjectsRoute: typeof SettingsProjectsRoute |
| 504 | SettingsProvidersRoute: typeof SettingsProvidersRoute | 525 | SettingsProvidersRoute: typeof SettingsProvidersRoute |
| 505 | SettingsSnapShotRoute: typeof SettingsSnapShotRoute | 526 | SettingsSnapShotRoute: typeof SettingsSnapShotRoute |
| ... | @@ -514,6 +535,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { | ... | @@ -514,6 +535,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { |
| 514 | SettingsGeneralRoute: SettingsGeneralRoute, | 535 | SettingsGeneralRoute: SettingsGeneralRoute, |
| 515 | SettingsIntegrationsRoute: SettingsIntegrationsRoute, | 536 | SettingsIntegrationsRoute: SettingsIntegrationsRoute, |
| 516 | SettingsKeybindingsRoute: SettingsKeybindingsRoute, | 537 | SettingsKeybindingsRoute: SettingsKeybindingsRoute, |
| 538 | SettingsOpenSourceLicensesRoute: SettingsOpenSourceLicensesRoute, | ||
| 517 | SettingsProjectsRoute: SettingsProjectsRoute, | 539 | SettingsProjectsRoute: SettingsProjectsRoute, |
| 518 | SettingsProvidersRoute: SettingsProvidersRoute, | 540 | SettingsProvidersRoute: SettingsProvidersRoute, |
| 519 | SettingsSnapShotRoute: SettingsSnapShotRoute, | 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,6 +32,7 @@ |
| 32 | "vercel.ts", | 32 | "vercel.ts", |
| 33 | "test", | 33 | "test", |
| 34 | "scripts/warm-dep-cache.ts", | 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,6 +12,7 @@ import pkg from "./package.json" with { type: "json" }; |
| 12 | import { DEV_PROXIED_PATH_PREFIXES } from "@t3tools/shared/devProxy"; | 12 | import { DEV_PROXIED_PATH_PREFIXES } from "@t3tools/shared/devProxy"; |
| 13 | 13 | ||
| 14 | import { loadRepoEnv } from "../../scripts/lib/public-config"; | 14 | import { loadRepoEnv } from "../../scripts/lib/public-config"; |
| 15 | import { thirdPartyLicensesPlugin } from "../../scripts/lib/third-party-licenses"; | ||
| 15 | import { tailwindPlugins } from "./vite/tailwind"; | 16 | import { tailwindPlugins } from "./vite/tailwind"; |
| 16 | 17 | ||
| 17 | const repoEnv = loadRepoEnv(); | 18 | const repoEnv = loadRepoEnv(); |
| ... | @@ -157,6 +158,15 @@ export default defineConfig(() => { | ... | @@ -157,6 +158,15 @@ export default defineConfig(() => { |
| 157 | assetsInclude: ["**/*.wasm"], | 158 | assetsInclude: ["**/*.wasm"], |
| 158 | plugins: [ | 159 | plugins: [ |
| 159 | devCompressionPlugin(), | 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 | // Route components load as split chunks so settings, pull-request, and | 170 | // Route components load as split chunks so settings, pull-request, and |
| 161 | // usage code stay out of the cold-start payload; the router prefetches | 171 | // usage code stay out of the cold-start payload; the router prefetches |
| 162 | // them on navigation intent (see getRouter's defaultPreload). | 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,6 +18,7 @@ |
| 18 | "screenshots:mobile": "node scripts/mobile-showcase.ts", | 18 | "screenshots:mobile": "node scripts/mobile-showcase.ts", |
| 19 | "icons:export": "node scripts/export-brand-icons.ts", | 19 | "icons:export": "node scripts/export-brand-icons.ts", |
| 20 | "icons:check": "node scripts/export-brand-icons.ts --check", | 20 | "icons:check": "node scripts/export-brand-icons.ts --check", |
| 21 | "licenses:sync": "node scripts/sync-third-party-license-notices.ts", | ||
| 21 | "icons:export:android": "node scripts/export-android-icons.ts", | 22 | "icons:export:android": "node scripts/export-android-icons.ts", |
| 22 | "build": "vp run --filter './apps/*' build", | 23 | "build": "vp run --filter './apps/*' build", |
| 23 | "build:marketing": "vp run --filter @t3tools/marketing build", | 24 | "build:marketing": "vp run --filter @t3tools/marketing build", |
packages/shared/package.json+4| ... | @@ -275,6 +275,10 @@ | ... | @@ -275,6 +275,10 @@ |
| 275 | "types": "./src/desktopAppControl.ts", | 275 | "types": "./src/desktopAppControl.ts", |
| 276 | "import": "./src/desktopAppControl.ts" | 276 | "import": "./src/desktopAppControl.ts" |
| 277 | }, | 277 | }, |
| 278 | "./thirdPartyLicenses": { | ||
| 279 | "types": "./src/thirdPartyLicenses.ts", | ||
| 280 | "import": "./src/thirdPartyLicenses.ts" | ||
| 281 | }, | ||
| 278 | "./claudeCompaction": { | 282 | "./claudeCompaction": { |
| 279 | "types": "./src/claudeCompaction.ts", | 283 | "types": "./src/claudeCompaction.ts", |
| 280 | "import": "./src/claudeCompaction.ts" | 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 | } | ||