Skip to content
Merged
9 changes: 8 additions & 1 deletion public/locales/en/components.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@
"logo-alt-text": "OpenSpace logo",
"about-openspace-description": "OpenSpace is open-source interactive data visualization software designed to visualize the entire known universe and portray our ongoing efforts to investigate the cosmos.",
"fetching-version-number": "Fetching OpenSpace version...",
"custom-version-number": "Custom"
"custom-version-number": "Custom",
"openspace-institutions": "OpenSpace is stewarded by Linköping University and the American Museum of Natural History with support from NASA, the Swedish e-Science Research Center, and the Knut and Alice Wallenberg Foundation.",
"contributors-title": "Contributors",
"contributors-loading": "Loading contributors...",
"contributors-error": "Failed to load contributors",
"third-party-title": "Third Party Licenses",
"third-party-license-label": "License",
"third-party-url-label": "URL"
},
"clear-button": {
"aria-label": "Clear"
Expand Down
135 changes: 135 additions & 0 deletions scripts/generateThirdParty.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* Preprocessing script that parses a third-party license markdown file and
* generates a static TypeScript data file at src/data/ThirdPartyDependencies.ts.
*
* Usage:
* npx tsx scripts/generateThirdParty.ts <path-to-license.md> [output-path]
*
* Example:
* npx tsx scripts/generateThirdParty.ts LICENSES.md
* npx tsx scripts/generateThirdParty.ts LICENSES.md src/data/ThirdPartyDependencies.ts
*
* Expected input format:
* A markdown file where each library has a section like:
*
* ## Library Name
*
* **License:** MIT
* **URL:** https://example.com
*
* <full license text>
*/

import fs from 'fs';
import path from 'path';
Comment thread
alexanderbock marked this conversation as resolved.

interface Dependency {
name: string;
license: string;
url: string;
licenseText: string;
}

function parseMarkdown(content: string): Dependency[] {
// Split into sections on lines that start a new ## heading.
// The first chunk is the intro/table — skip it.
const chunks = content.split(/\n(?=## )/);

const dependencies: Dependency[] = [];

for (const chunk of chunks.slice(1)) {
const lines = chunk.split('\n');

const name = lines[0].replace(/^##\s+/, '').trim();

const licenseMatch = chunk.match(/^\*\*License:\*\*\s*(.+)$/m);
const urlMatch = chunk.match(/^\*\*URL:\*\*\s*(.+)$/m);

if (!licenseMatch || !urlMatch) {
console.warn(`Skipping section "${name}": missing License or URL field.`);
continue;
}

const license = licenseMatch[1].trim();
const url = urlMatch[1].trim();

// License text is everything after the **URL:** line.
const urlLineIndex = lines.findIndex((l) => l.startsWith('**URL:**'));
const licenseText = lines
.slice(urlLineIndex + 1)
.join('\n')
.trim();

dependencies.push({ name, license, url, licenseText });
}

return dependencies;
}

function generateTypeScript(dependencies: Dependency[]): string {
const entries = dependencies
.map((dep) => {
return [
` {`,
` name: ${JSON.stringify(dep.name)},`,
` license: ${JSON.stringify(dep.license)},`,
` url: ${JSON.stringify(dep.url)},`,
` licenseText: ${JSON.stringify(dep.licenseText)}`,
` }`
].join('\n');
})
.join(',\n');

return `// This file is auto-generated. Do not edit manually.
// To regenerate, run:
// npx tsx scripts/generateThirdParty.ts <path-to-license.md>

export interface ThirdPartyDependency {
name: string;
license: string;
url: string;
licenseText: string;
}

export const ThirdPartyDependencies: ThirdPartyDependency[] = [
${entries}
];
`;
}

async function main() {
const inputPath = process.argv[2];
if (!inputPath) {
console.error('Error: No input file specified.');
console.error('Usage: npx tsx scripts/generateThirdParty.ts <path-to-license.md>');
process.exit(1);
}

const resolvedInput = path.resolve(inputPath);
if (!fs.existsSync(resolvedInput)) {
console.error(`Error: File not found: ${resolvedInput}`);
process.exit(1);
}

const outputPath = path.resolve(
process.argv[3] ?? 'src/data/ThirdPartyDependencies.ts'
);

const content = fs.readFileSync(resolvedInput, 'utf-8');
const dependencies = parseMarkdown(content);

if (dependencies.length === 0) {
console.error('Error: No dependency sections found in the input file.');
process.exit(1);
}

const output = generateTypeScript(dependencies);

fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, output, 'utf-8');

Comment thread
alexanderbock marked this conversation as resolved.
console.log(`Parsed ${dependencies.length} dependencies.`);
console.log(`Written to: ${outputPath}`);
}

main();
138 changes: 123 additions & 15 deletions src/components/About/About.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,25 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Anchor, Grid, Image, Modal, Stack, Text, Title } from '@mantine/core';
import {
Anchor,
Avatar,
Box,
Code,
Container,
Group,
Image,
Modal,
SimpleGrid,
Stack,
Text,
Title
} from '@mantine/core';
import { SemanticVersion } from 'openspace-api-js/types';

import { useAppSelector } from '@/redux/hooks';
import { Collapsable } from '@/components/Collapsable/Collapsable';
import { ThirdPartyDependencies } from '@/data/ThirdPartyDependencies';
import { getContributors } from '@/redux/contributors/contributorsMiddleware';
import { useAppDispatch, useAppSelector } from '@/redux/hooks';
Comment on lines +19 to +22

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ehm.. Help?

@WeirdRubberDuck WeirdRubberDuck Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know how much of a problem this actually is based on the size of the dependencies list, but it makes a good point.

The about page is rarely loaded, but now every user pays the "cost" of statically loading this data file. In this case, it seems to suggest loading this data as part of the useEffect (i.e. when the modal opens). This would mean doing something like this:

import { useEffect, useState } from "react";

export function About({ opened }) {
  const [licenses, setLicenses] = useState(null);

  useEffect(() => {
    if (!opened || licenses) {
      return;
    }

    async function load() {
      const module = await import("@/data/ThirdPartyDependencies");
      setLicenses(module.ThirdPartyDependencies);
    }
    load();
  }, [opened, licenses]);

  return (
     // Other rendering code...
    <Title order={3} mt={'md'} mb={'xs'}>
       {t('third-party-title')}
    </Title>
    { licenses && /** Render the licence list **/ }
  );
}

An alternative could be to move the third-party licences part into a separate component, and then use React. lazy to lazily load that entire component:

  // This component statically loads a large json file containing all third party
  // dependencies and their licenses. To avoid loading this file when the About modal is
  // not open, we lazy load the component that renders the list of dependencies
  const ThirdPartyDependenciesList = React.lazy(() =>
    import('./ThirdPartyDependenciesList').then((module) => ({
      default: module.ThirdPartyDependenciesList
    }))
  );

  // In the render function: 
  <Title order={3} mt={'md'} mb={'xs'}>
    {t('third-party-title')}
  </Title>
  <Suspense fallback={<Loader />}>
    <ThirdPartyDependenciesList />
  </Suspense>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it is not that big of a deal (file is about 200 lines and not going to grow that much more), I guess we can keep it as it is as well


interface Props {
opened: boolean;
Expand All @@ -12,7 +29,16 @@ interface Props {
export function About({ opened, close }: Props) {
const { t } = useTranslation('components', { keyPrefix: 'about' });

const dispatch = useAppDispatch();
const openSpaceVersion = useAppSelector((state) => state.version.openSpaceVersion);
const contributors = useAppSelector((state) => state.contributors.contributors);
const contributorsStatus = useAppSelector((state) => state.contributors.status);

useEffect(() => {
if (opened && (contributorsStatus === 'idle' || contributorsStatus === 'failed')) {
dispatch(getContributors());
}
}, [opened, contributorsStatus, dispatch]);
Comment thread
Copilot marked this conversation as resolved.

function osVersionNumber(): string {
if (!openSpaceVersion) {
Expand All @@ -33,31 +59,113 @@ export function About({ opened, close }: Props) {
opened={opened}
onClose={close}
title={t('modal-title')}
size={'40%'}
size={'xxl'}
closeButtonProps={{ 'aria-label': 'Close about' }}
scrollAreaComponent={Modal.NativeScrollArea}
Comment thread
alexanderbock marked this conversation as resolved.
>
<Grid>
<Grid.Col span={4}>
<Image
src={`${import.meta.env.BASE_URL}/images/openspace-logo.png`}
alt={t('logo-alt-text')}
w={'100%'}
/>
</Grid.Col>
<Grid.Col span={8}>
<Container px={'xs'}>
<Group wrap={'nowrap'} align={'top'}>
<Box px={'xs'}>
<Image
src={`${import.meta.env.BASE_URL}/images/openspace-logo.png`}
alt={t('logo-alt-text')}
w={{ xs: 100, sm: 180 }}
fit={'contain'}
/>
</Box>
<Stack gap={'xs'}>
<Title order={1}>OpenSpace</Title>
<Text>{t('about-openspace-description')}</Text>
<Text>{osVersionNumber()}</Text>
<Text>
&copy; 2014 - {new Date().getFullYear()} OpenSpace Development Team
</Text>
<Anchor href={'https://www.openspaceproject.com/'} target={'_blank'}>
<Anchor
href={'https://www.openspaceproject.com/'}
target={'_blank'}
rel={'noopener noreferrer'}
>
openspaceproject.com
</Anchor>
<Text>{t('openspace-institutions')}</Text>
</Stack>
</Grid.Col>
</Grid>
</Group>

<Title order={3}>{t('contributors-title')}</Title>
<div style={{ marginTop: 'var(--mantine-spacing-xs)' }}>
{contributorsStatus === 'loading' && (
<Text c={'dimmed'}>{t('contributors-loading')}</Text>
)}
{contributorsStatus === 'failed' && (
<Text c={'red'}>{t('contributors-error')}</Text>
)}
{contributorsStatus === 'succeeded' && (
<SimpleGrid cols={{ base: 6, xs: 10, sm: 12, md: 14 }} spacing={'xs'}>
{contributors.map((contributor) => (
<Anchor
key={contributor.login}
href={contributor.htmlUrl}
target={'_blank'}
rel={'noopener noreferrer'}
underline={'never'}
c={'inherit'}
>
<Stack align={'center'} gap={4}>
<Avatar
src={contributor.avatarUrl}
alt={contributor.login}
size={'md'}
/>
<Text size={'xs'} ta={'center'} style={{ wordBreak: 'break-all' }}>
{contributor.login}
</Text>
</Stack>
</Anchor>
))}
</SimpleGrid>
)}
</div>

<Title order={3} mt={'md'} mb={'xs'}>
{t('third-party-title')}
</Title>
<Stack gap={0}>
{ThirdPartyDependencies.map((dep) => (
<Collapsable key={dep.name} title={dep.name} noTransition>
<Stack gap={'xs'} pb={'xs'}>
<Text size={'sm'}>
<Text span fw={600}>
{t('third-party-license-label')}:{' '}
</Text>
{dep.license}
</Text>
<Text size={'sm'}>
<Text span fw={600}>
{t('third-party-url-label')}:{' '}
</Text>
<Anchor
href={dep.url}
target={'_blank'}
rel={'noopener noreferrer'}
size={'sm'}
>
{dep.url}
</Anchor>
</Text>
<Code
block
style={{
whiteSpace: 'pre-wrap',
fontSize: 'var(--mantine-font-size-xs)'
}}
>
{dep.licenseText}
</Code>
</Stack>
</Collapsable>
))}
</Stack>
</Container>
</Modal>
);
}
Loading
Loading