Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 74 additions & 19 deletions scripts/generate-content-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ async function readJsonFile<T = unknown>(absolutePath: string): Promise<T> {
}
}

async function readJsonObjectFile(absolutePath: string): Promise<Record<string, unknown>> {
async function readJsonObjectFile(
absolutePath: string,
): Promise<Record<string, unknown>> {
const parsed = await readJsonFile(absolutePath);
if (!isRecord(parsed)) {
throw new Error(`Invalid JSON at ${absolutePath}: expected object.`);
Expand All @@ -46,7 +48,9 @@ function assertValidVideoChannel(
channel: unknown,
): void {
if (!isRecord(channel)) {
throw new Error(`Invalid video.json at ${absolutePath}: expected video.channel object.`);
throw new Error(
`Invalid video.json at ${absolutePath}: expected video.channel object.`,
);
}

if (channel.platform !== platform) {
Expand Down Expand Up @@ -83,11 +87,15 @@ async function assertValidVideoJson(

// Invariant: video.json must match its on-disk location under content/platforms/{platform}/videos/{videoId}.
if (raw.videoId !== videoId) {
throw new Error(`Invalid video.json at ${absolutePath}: expected videoId '${videoId}'.`);
throw new Error(
`Invalid video.json at ${absolutePath}: expected videoId '${videoId}'.`,
);
}

if (typeof raw.videoUrl !== 'string' || !raw.videoUrl) {
throw new Error(`Invalid video.json at ${absolutePath}: expected non-empty videoUrl.`);
throw new Error(
`Invalid video.json at ${absolutePath}: expected non-empty videoUrl.`,
);
}

if (typeof raw.title !== 'string' || !raw.title) {
Expand Down Expand Up @@ -119,6 +127,25 @@ async function listVideoIds(platform: Platform): Promise<string[]> {
return out.sort();
}

async function listFileNames(absoluteDir: string): Promise<Set<string>> {
// 1 readdir per video directory (faster than multiple per-file stats).
try {
const entries = await readdir(absoluteDir, { withFileTypes: true });
const out = new Set<string>();
for (const entry of entries) {
if (entry.isFile()) out.add(entry.name);
}
return out;
} catch (error) {
process.stderr.write(
`Warning: Failed to read content directory ${absoluteDir}: ${
error instanceof Error ? error.message : String(error)
}\n`,
);
return new Set();
}
}
Comment on lines +130 to +147
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

listFileNames() returns an empty set on any readdir failure and the generator then silently treats comments.json / analytics.json / report.mdx as missing. In --strict mode you already fail on invalid video.json, but this path can still quietly produce a degraded content index if the directory is unreadable (permissions, transient FS errors, etc.). That’s a correctness issue because it can hide real problems behind “pending analysis” UX.

Given this script drives build-time imports, a directory read error is usually something you want to fail hard on in strict mode (or at least for video.json, which should always exist for validEntries).

Suggestion

Make directory read failures respect --strict, and/or verify video.json is present even when the directory listing fails.

Example:

async function listFileNames(absoluteDir: string, strict: boolean): Promise<Set<string>> {
  try {
    const entries = await readdir(absoluteDir, { withFileTypes: true });
    const out = new Set<string>();
    for (const entry of entries) if (entry.isFile()) out.add(entry.name);
    return out;
  } catch (error) {
    const msg = `Failed to read content directory ${absoluteDir}: ${
      error instanceof Error ? error.message : String(error)
    }`;
    if (strict) throw new Error(msg);
    process.stderr.write(`Warning: ${msg}\n`);
    return new Set();
  }
}

Then call await listFileNames(base, strict).

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this change.


function relFromGenerated(absolutePath: string): string {
const rel = path.relative(OUT_DIR, absolutePath);
return rel.startsWith('.') ? rel : `./${rel}`;
Expand All @@ -140,7 +167,13 @@ async function main(): Promise<void> {

const validations: Validation[] = await Promise.all(
videoEntries.map(async ({ platform, videoId }) => {
const videoJsonPath = path.join(CONTENT_ROOT, platform, 'videos', videoId, 'video.json');
const videoJsonPath = path.join(
CONTENT_ROOT,
platform,
'videos',
videoId,
'video.json',
);
try {
await assertValidVideoJson(videoJsonPath, platform, videoId);
return { ok: true as const, platform, videoId };
Expand All @@ -150,10 +183,13 @@ async function main(): Promise<void> {
}),
);

const invalidEntries = validations.filter((v): v is Extract<Validation, { ok: false }> => !v.ok);
const invalidEntries = validations.filter(
(v): v is Extract<Validation, { ok: false }> => !v.ok,
);
if (invalidEntries.length > 0) {
const messages = invalidEntries.map((entry) => {
const detail = entry.error instanceof Error ? entry.error.message : String(entry.error);
const detail =
entry.error instanceof Error ? entry.error.message : String(entry.error);
return `[${entry.platform}:${entry.videoId}] ${detail}`;
});

Expand All @@ -166,7 +202,9 @@ async function main(): Promise<void> {
}
}

const validEntries = validations.filter((v): v is Extract<Validation, { ok: true }> => v.ok);
const validEntries = validations.filter(
(v): v is Extract<Validation, { ok: true }> => v.ok,
);

const contentImports: string[] = [
'// AUTO-GENERATED FILE. DO NOT EDIT.',
Expand All @@ -175,7 +213,9 @@ async function main(): Promise<void> {
"import type { VideoContent } from '../types';",
'',
];
const contentMapLines: string[] = ['export const VIDEO_CONTENT: Record<string, VideoContent> = {'];
const contentMapLines: string[] = [
'export const VIDEO_CONTENT: Record<string, VideoContent> = {',
];

const reportImports: string[] = [
'// AUTO-GENERATED FILE. DO NOT EDIT.',
Expand All @@ -192,29 +232,44 @@ async function main(): Promise<void> {
const ident = safeIdent(`${platform}_${videoId}`);
const base = path.join(CONTENT_ROOT, platform, 'videos', videoId);

const videoPath = relFromGenerated(path.join(base, 'video.json'));
const commentsPath = relFromGenerated(path.join(base, 'comments.json'));
const analyticsPath = relFromGenerated(path.join(base, 'analytics.json'));
const reportPath = relFromGenerated(path.join(base, 'report.mdx'));
const files = await listFileNames(base);

const videoAbs = path.join(base, 'video.json');
const commentsAbs = path.join(base, 'comments.json');
const analyticsAbs = path.join(base, 'analytics.json');
const reportAbs = path.join(base, 'report.mdx');

const hasComments = files.has('comments.json');
const hasAnalytics = files.has('analytics.json');
const hasReport = files.has('report.mdx');

const videoPath = relFromGenerated(videoAbs);
const commentsPath = relFromGenerated(commentsAbs);
const analyticsPath = relFromGenerated(analyticsAbs);
const reportPath = relFromGenerated(reportAbs);

contentImports.push(
`import ${ident}_video from '${videoPath}';`,
`import ${ident}_comments from '${commentsPath}';`,
`import ${ident}_analytics from '${analyticsPath}';`,
...(hasComments ? [`import ${ident}_comments from '${commentsPath}';`] : []),
...(hasAnalytics ? [`import ${ident}_analytics from '${analyticsPath}';`] : []),
'',
);

reportImports.push(`import ${ident}_report from '${reportPath}';`, '');
if (hasReport) {
reportImports.push(`import ${ident}_report from '${reportPath}';`, '');
}

contentMapLines.push(
` '${platform}:${videoId}': {`,
` video: ${ident}_video as VideoContent['video'],`,
` comments: ${ident}_comments,`,
` analytics: ${ident}_analytics,`,
` comments: ${hasComments ? `${ident}_comments` : 'undefined'},`,
` analytics: ${hasAnalytics ? `${ident}_analytics` : 'undefined'},`,
' },',
);

reportMapLines.push(` '${platform}:${videoId}': ${ident}_report,`);
reportMapLines.push(
` '${platform}:${videoId}': ${hasReport ? `${ident}_report` : 'undefined'},`,
);
}

contentMapLines.push('};', '');
Expand Down
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Route, Routes } from 'react-router-dom';

import { Layout } from './components/Layout';
import { JobsPage } from './pages/JobsPage';
import { LibraryPage } from './pages/LibraryPage';
import { OnboardingPage } from './pages/OnboardingPage';
import { VideoAnalyticsPage } from './pages/VideoAnalyticsPage';
Expand All @@ -10,6 +11,7 @@ export function App(): JSX.Element {
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<OnboardingPage />} />
<Route path="/jobs" element={<JobsPage />} />
<Route path="/library" element={<LibraryPage />} />
<Route path="/video/:platform/:videoId" element={<VideoAnalyticsPage />} />
</Route>
Expand Down
8 changes: 8 additions & 0 deletions src/components/NavBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ export function NavBar(): JSX.Element {
>
Library
</Link>
<Link
to="/jobs"
className={
location.pathname.startsWith('/jobs') ? 'nav-link active' : 'nav-link'
}
>
Jobs
</Link>
</nav>
</div>
<div className="nav-right">
Expand Down
10 changes: 8 additions & 2 deletions src/content/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,14 @@ export type CommentAnalytics = {
gentleCritiques: string[];
};

/**
* Build-time content for a video.
*
* `comments` and `analytics` are optional to support partial ingestion states
* (e.g. comments are captured, but analysis/report haven't run yet).
*/
export type VideoContent = {
video: VideoMetadata;
comments: CommentRecord[];
analytics: CommentAnalytics;
comments?: CommentRecord[];
analytics?: CommentAnalytics;
};
Loading