feat(content-strategy): Introduce content analysis scanning module with new pages, navigation updates, and utilities

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Lilith 2026-02-28 12:48:31 -08:00
parent 3117a299e6
commit 0ed1b14022
10 changed files with 733 additions and 16 deletions

View file

@ -6,8 +6,14 @@ import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const dataDir = resolve(__dirname, '../src/data');
const contentDir = resolve(__dirname, '../../../../../docs/content');
const outputPath = resolve(dataDir, 'file-status.json');
function resolveContentPath(filePath: string): string {
const stripped = filePath.replace(/^(\.\.\/)+/, '');
return resolve(contentDir, stripped);
}
interface CalendarPeriod {
id: string;
items: Array<{
@ -65,17 +71,15 @@ function main(): void {
continue;
}
if (item.file && !files[item.file]) {
const absPath = resolve(dataDir, item.file);
files[item.file] = {
exists: existsSync(absPath),
exists: existsSync(resolveContentPath(item.file)),
source: 'calendar',
period: period.id,
};
}
if (item.file2 && !files[item.file2]) {
const absPath = resolve(dataDir, item.file2);
files[item.file2] = {
exists: existsSync(absPath),
exists: existsSync(resolveContentPath(item.file2)),
source: 'calendar',
period: period.id,
};
@ -88,9 +92,8 @@ function main(): void {
for (const sub of domain.subsections) {
for (const file of sub.files) {
if (file.path && !files[file.path]) {
const absPath = resolve(dataDir, file.path);
files[file.path] = {
exists: existsSync(absPath),
exists: existsSync(resolveContentPath(file.path)),
source: 'domains',
};
}

View file

@ -20,6 +20,8 @@ import { ThreadInterplayPage } from './pages/ThreadInterplayPage';
import { StoryArcPage } from './pages/StoryArcPage';
import { FileHealthPage } from './pages/FileHealthPage';
import { MerchPage } from './pages/MerchPage';
import { DocumentViewerPage } from './pages/DocumentViewerPage';
import { ContentReadinessPage } from './pages/ContentReadinessPage';
logVersionBanner({ primaryColor: '#00ffff', secondaryColor: '#ff00ff' });
@ -54,6 +56,8 @@ export function App(): JSX.Element {
<Route path="/press" element={withErrorBoundary(<PressPage />)} />
<Route path="/health" element={withErrorBoundary(<FileHealthPage />)} />
<Route path="/merch" element={withErrorBoundary(<MerchPage />)} />
<Route path="/view/*" element={withErrorBoundary(<DocumentViewerPage />)} />
<Route path="/readiness" element={withErrorBoundary(<ContentReadinessPage />)} />
<Route
path="*"
element={

View file

@ -0,0 +1,34 @@
/** @jsxImportSource react */
import type { JSX } from 'react';
import { Link } from '@lilith/ui-router';
import styled from '@lilith/ui-styled-components';
import { viewerUrl } from '@/utils/content-paths';
const StyledLink = styled(Link)`
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
color: #818cf8;
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
text-decoration: underline;
color: #a5b4fc;
}
`;
interface FileLinkProps {
path: string;
className?: string;
}
export function FileLink({ path, className }: FileLinkProps): JSX.Element {
return (
<StyledLink to={viewerUrl(path)} className={className} title={path}>
{path}
</StyledLink>
);
}

View file

@ -9,6 +9,11 @@ export const NAVIGATION_SECTIONS: NavSection[] = [
label: 'Dashboard',
description: 'Content strategy metrics overview',
},
{
to: '/readiness',
label: 'Readiness',
description: 'File type coverage matrix by content topic',
},
],
},
{

View file

@ -0,0 +1,370 @@
/** @jsxImportSource react */
import type { JSX } from 'react';
import { useState, useMemo } from 'react';
import styled from '@lilith/ui-styled-components';
import { Badge } from '@lilith/ui-primitives';
import { useContentData } from '@/hooks/useContentData';
import type { ContentItem, ContentFileType, ContentFileTypeMeta, Group } from '@/types/content';
const PageWrapper = styled.div`
padding: 1.5rem;
`;
const FilterBar = styled.div`
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 1.5rem;
`;
const FilterButton = styled.button<{ $active: boolean; $color: string }>`
padding: 0.4rem 1rem;
border-radius: 6px;
border: 1px solid ${(p) => (p.$active ? p.$color : '#444')};
background: ${(p) => (p.$active ? `${p.$color}22` : 'transparent')};
color: ${(p) => (p.$active ? p.$color : '#888')};
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
&:hover {
border-color: ${(p) => p.$color};
color: ${(p) => p.$color};
}
`;
const TableContainer = styled.div`
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
overflow-x: auto;
`;
const Table = styled.table`
width: 100%;
border-collapse: collapse;
min-width: 800px;
`;
const Thead = styled.thead`
border-bottom: 2px solid #333;
`;
const ThRotated = styled.th`
height: 120px;
width: 48px;
min-width: 48px;
padding: 0;
vertical-align: bottom;
text-align: center;
`;
const RotatedLabel = styled.div`
writing-mode: vertical-rl;
text-orientation: mixed;
transform: rotate(180deg);
font-size: 0.7rem;
font-weight: 600;
color: #888;
letter-spacing: 0.03em;
padding-bottom: 0.5rem;
white-space: nowrap;
`;
const ThTopic = styled.th`
text-align: left;
padding: 0.75rem 1rem;
font-size: 0.7rem;
font-weight: 700;
color: #888;
text-transform: uppercase;
letter-spacing: 0.04em;
`;
const GroupHeaderRow = styled.tr`
background: #242424;
border-top: 1px solid #444;
`;
const GroupHeaderCell = styled.td`
padding: 0.6rem 1rem;
font-size: 0.85rem;
font-weight: 600;
color: #fff;
`;
const GroupProgress = styled.div`
display: flex;
align-items: center;
gap: 0.5rem;
`;
const ProgressBar = styled.div<{ $pct: number; $color: string }>`
width: 80px;
height: 6px;
background: #333;
border-radius: 3px;
overflow: hidden;
flex-shrink: 0;
&::after {
content: '';
display: block;
width: ${(p) => p.$pct}%;
height: 100%;
background: ${(p) => p.$color};
border-radius: 3px;
transition: width 0.3s;
}
`;
const ProgressText = styled.span`
font-size: 0.7rem;
font-family: 'JetBrains Mono', monospace;
color: #888;
`;
const ItemRow = styled.tr`
border-bottom: 1px solid #222;
&:hover {
background: #1e1e1e;
}
`;
const TopicCell = styled.td`
padding: 0.4rem 1rem;
font-size: 0.8rem;
color: #e0e0e0;
`;
const DotCell = styled.td`
text-align: center;
padding: 0.4rem 0;
font-size: 1rem;
line-height: 1;
`;
const FilledDot = styled.span`
color: #34d399;
`;
const EmptyDot = styled.span`
color: #555;
`;
const StatusBadgeWrapper = styled.span`
margin-right: 0.5rem;
`;
const STATUS_COLORS: Record<string, string> = {
draft: '#818cf8',
seeded: '#fbbf24',
new: '#34d399',
blocked: '#f87171',
};
interface GroupedItems {
group: Group;
items: ContentItem[];
completed: number;
total: number;
}
export function ContentReadinessPage(): JSX.Element {
const { contentItems, groups, fileTypes } = useContentData();
const [activeGroup, setActiveGroup] = useState<string | null>(null);
const groupMap = useMemo(() => {
const map = new Map<string, Group>();
for (const g of groups) {
map.set(g.key, g);
}
return map;
}, [groups]);
const grouped = useMemo<GroupedItems[]>(() => {
const byGroup = new Map<string, ContentItem[]>();
for (const item of contentItems) {
const existing = byGroup.get(item.group);
if (existing) {
existing.push(item);
} else {
byGroup.set(item.group, [item]);
}
}
const result: GroupedItems[] = [];
for (const group of groups) {
const items = byGroup.get(group.key);
if (!items) continue;
const completed = items.filter(
(i) => i.status === 'draft' || i.status === 'seeded',
).length;
result.push({
group,
items,
completed,
total: items.length,
});
}
// Add any groups from data that aren't in the groups list
for (const [key, items] of byGroup) {
if (!groupMap.has(key)) {
const completed = items.filter(
(i) => i.status === 'draft' || i.status === 'seeded',
).length;
result.push({
group: { key, label: key, color: '#888', week: '' },
items,
completed,
total: items.length,
});
}
}
return result;
}, [contentItems, groups, groupMap]);
const filteredGroups = useMemo(() => {
if (!activeGroup) return grouped;
return grouped.filter((g) => g.group.key === activeGroup);
}, [grouped, activeGroup]);
const fileTypeColumns: ContentFileTypeMeta[] = fileTypes;
return (
<PageWrapper>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, marginBottom: '0.25rem' }}>
Content Readiness
</h1>
<p style={{ color: '#888', fontSize: '0.875rem', marginBottom: '2rem' }}>
File type coverage matrix &middot; {contentItems.length} topics across{' '}
{grouped.length} groups &middot; {fileTypeColumns.length} deliverable types
</p>
<FilterBar>
<FilterButton
$active={!activeGroup}
$color="#fff"
onClick={() => setActiveGroup(null)}
>
All ({grouped.length})
</FilterButton>
{grouped.map((g) => (
<FilterButton
key={g.group.key}
$active={activeGroup === g.group.key}
$color={g.group.color}
onClick={() =>
setActiveGroup(activeGroup === g.group.key ? null : g.group.key)
}
>
{g.group.label} ({g.items.length})
</FilterButton>
))}
</FilterBar>
<TableContainer>
<Table>
<Thead>
<tr>
<ThTopic style={{ width: '40%' }}>Topic</ThTopic>
{fileTypeColumns.map((ft) => (
<ThRotated key={ft.key}>
<RotatedLabel>{ft.label}</RotatedLabel>
</ThRotated>
))}
</tr>
</Thead>
<tbody>
{filteredGroups.map((g) => {
const pct =
g.total > 0 ? Math.round((g.completed / g.total) * 100) : 0;
return (
<GroupRows
key={g.group.key}
group={g}
pct={pct}
fileTypeColumns={fileTypeColumns}
/>
);
})}
</tbody>
</Table>
</TableContainer>
</PageWrapper>
);
}
interface GroupRowsProps {
group: GroupedItems;
pct: number;
fileTypeColumns: ContentFileTypeMeta[];
}
function GroupRows({ group, pct, fileTypeColumns }: GroupRowsProps): JSX.Element {
return (
<>
<GroupHeaderRow>
<GroupHeaderCell colSpan={fileTypeColumns.length + 1}>
<GroupProgress>
<span
style={{
width: 10,
height: 10,
borderRadius: '50%',
background: group.group.color,
display: 'inline-block',
flexShrink: 0,
}}
/>
<span>{group.group.label}</span>
<ProgressBar $pct={pct} $color={group.group.color} />
<ProgressText>
{group.completed}/{group.total} complete
</ProgressText>
</GroupProgress>
</GroupHeaderCell>
</GroupHeaderRow>
{group.items.map((item, idx) => {
const isDone = item.status === 'draft' || item.status === 'seeded';
const fileSet = new Set<ContentFileType>(item.files);
return (
<ItemRow key={`${item.group}-${idx}`}>
<TopicCell>
<StatusBadgeWrapper>
<Badge
color={STATUS_COLORS[item.status] ?? '#888'}
size="sm"
>
{item.status}
</Badge>
</StatusBadgeWrapper>
{item.topic}
</TopicCell>
{fileTypeColumns.map((ft) => (
<DotCell key={ft.key}>
{fileSet.has(ft.key) ? (
isDone ? (
<FilledDot title={`${ft.label}: done`}></FilledDot>
) : (
<EmptyDot title={`${ft.label}: planned`}></EmptyDot>
)
) : null}
</DotCell>
))}
</ItemRow>
);
})}
</>
);
}

View file

@ -0,0 +1,285 @@
/** @jsxImportSource react */
import type { JSX } from 'react';
import { useState, useEffect } from 'react';
import { useLocation, Link } from '@lilith/ui-router';
import styled from '@lilith/ui-styled-components';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { contentApiUrl } from '@/utils/content-paths';
const PageWrapper = styled.div`
padding: 1.5rem;
max-width: 900px;
`;
const Breadcrumbs = styled.nav`
display: flex;
align-items: center;
gap: 0.35rem;
margin-bottom: 1.5rem;
font-size: 0.8rem;
font-family: 'JetBrains Mono', monospace;
`;
const BreadcrumbLink = styled(Link)`
color: #818cf8;
text-decoration: none;
&:hover {
text-decoration: underline;
color: #a5b4fc;
}
`;
const BreadcrumbSeparator = styled.span`
color: #555;
`;
const BreadcrumbCurrent = styled.span`
color: #bbb;
`;
const LoadingContainer = styled.div`
padding: 3rem;
text-align: center;
color: #888;
font-size: 0.9rem;
`;
const NotFoundContainer = styled.div`
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
padding: 3rem;
text-align: center;
`;
const NotFoundTitle = styled.h2`
font-size: 1.25rem;
font-weight: 600;
color: #f87171;
margin-bottom: 0.75rem;
`;
const NotFoundPath = styled.code`
display: block;
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
color: #888;
margin-bottom: 1.5rem;
`;
const BackLink = styled(Link)`
color: #818cf8;
text-decoration: none;
font-size: 0.875rem;
&:hover {
text-decoration: underline;
}
`;
const MarkdownContainer = styled.div`
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
padding: 2rem;
color: #e0e0e0;
line-height: 1.7;
h1 {
font-size: 1.75rem;
font-weight: 700;
color: #fff;
margin: 0 0 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #333;
}
h2 {
font-size: 1.35rem;
font-weight: 600;
color: #fff;
margin: 1.75rem 0 0.75rem;
}
h3 {
font-size: 1.1rem;
font-weight: 600;
color: #ddd;
margin: 1.5rem 0 0.5rem;
}
p {
margin: 0.75rem 0;
}
a {
color: #818cf8;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
ul,
ol {
padding-left: 1.5rem;
margin: 0.75rem 0;
}
li {
margin: 0.35rem 0;
}
blockquote {
border-left: 3px solid #818cf8;
margin: 1rem 0;
padding: 0.5rem 1rem;
color: #bbb;
background: #242424;
border-radius: 0 6px 6px 0;
}
code {
font-family: 'JetBrains Mono', monospace;
font-size: 0.85em;
background: #2a2a2a;
padding: 0.15rem 0.4rem;
border-radius: 4px;
color: #c084fc;
}
pre {
background: #0d0d0d;
border: 1px solid #333;
border-radius: 8px;
padding: 1rem;
overflow-x: auto;
margin: 1rem 0;
code {
background: none;
padding: 0;
color: #e0e0e0;
}
}
table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
}
th,
td {
border: 1px solid #333;
padding: 0.5rem 0.75rem;
text-align: left;
font-size: 0.85rem;
}
th {
background: #242424;
font-weight: 600;
color: #fff;
}
hr {
border: none;
border-top: 1px solid #333;
margin: 1.5rem 0;
}
img {
max-width: 100%;
border-radius: 8px;
}
strong {
color: #fff;
}
em {
color: #ccc;
}
`;
type ViewerState = 'loading' | 'loaded' | 'not_found';
export function DocumentViewerPage(): JSX.Element {
const location = useLocation();
const contentPath = location.pathname.replace(/^\/view\//, '');
const [state, setState] = useState<ViewerState>('loading');
const [markdown, setMarkdown] = useState('');
useEffect(() => {
setState('loading');
setMarkdown('');
fetch(contentApiUrl(contentPath))
.then((res) => {
if (!res.ok) {
setState('not_found');
return;
}
return res.text();
})
.then((text) => {
if (text !== undefined) {
setMarkdown(text);
setState('loaded');
}
})
.catch(() => {
setState('not_found');
});
}, [contentPath]);
const segments = contentPath.split('/').filter(Boolean);
return (
<PageWrapper>
<Breadcrumbs>
<BreadcrumbLink to="/">Home</BreadcrumbLink>
{segments.map((segment, i) => {
const isLast = i === segments.length - 1;
return (
<span key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
<BreadcrumbSeparator>/</BreadcrumbSeparator>
{isLast ? (
<BreadcrumbCurrent>{segment}</BreadcrumbCurrent>
) : (
<BreadcrumbCurrent>{segment}</BreadcrumbCurrent>
)}
</span>
);
})}
</Breadcrumbs>
{state === 'loading' && (
<LoadingContainer>Loading document...</LoadingContainer>
)}
{state === 'not_found' && (
<NotFoundContainer>
<NotFoundTitle>Not Written Yet</NotFoundTitle>
<NotFoundPath>{contentPath}</NotFoundPath>
<p style={{ color: '#888', fontSize: '0.875rem', marginBottom: '1.5rem' }}>
This content file has not been created yet. It exists as a planned item in the content calendar.
</p>
<BackLink to="/health">Back to File Health</BackLink>
</NotFoundContainer>
)}
{state === 'loaded' && (
<MarkdownContainer>
<Markdown remarkPlugins={[remarkGfm]}>{markdown}</Markdown>
</MarkdownContainer>
)}
</PageWrapper>
);
}

View file

@ -7,6 +7,7 @@ import { Badge } from '@lilith/ui-primitives';
import { BarChart } from '@lilith/ui-charts';
import { useContentData } from '@/hooks/useContentData';
import type { DomainTree, FileStatusManifest } from '@/types/content';
import { FileLink } from '@/components/FileLink';
const PageWrapper = styled.div`
padding: 1.5rem;
@ -107,11 +108,6 @@ const FileRow = styled.div`
}
`;
const FileLink = styled.span`
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
color: #bbb;
`;
const FileMeta = styled.div`
display: flex;
@ -185,7 +181,7 @@ function DomainSection({ domain, defaultOpen, fileStatusManifest }: DomainSectio
$exists={exists}
title={exists ? 'File exists' : 'File missing'}
/>
<FileLink>{file.name}</FileLink>
<FileLink path={file.path} />
</div>
<FileMeta>
<Badge

View file

@ -7,6 +7,7 @@ import { Badge } from '@lilith/ui-primitives';
import { MetricCard, DashboardLayout, DashboardWidget } from '@lilith/ui-analytics';
import { useContentData } from '@/hooks/useContentData';
import { FileLink } from '@/components/FileLink';
const PageWrapper = styled.div`
padding: 1.5rem;
`;
@ -263,9 +264,11 @@ export function FileHealthPage(): JSX.Element {
<Badge variant="primary" size="sm">{row.period}</Badge>
</span>
<span>{row.title}</span>
<FilePath title={row.file ?? 'No file assigned'}>
{row.file ?? '\u2014'}
</FilePath>
{row.file ? (
<FileLink path={row.file} />
) : (
<FilePath>\u2014</FilePath>
)}
<StatusDot $color={statusColor(row.status)}>
{statusLabel(row.status)}
</StatusDot>

View file

@ -5,6 +5,7 @@ import { useState } from 'react';
import styled from '@lilith/ui-styled-components';
import { Badge } from '@lilith/ui-primitives';
import { useContentData } from '@/hooks/useContentData';
import { FileLink } from '@/components/FileLink';
const PageWrapper = styled.div`
padding: 1.5rem;
@ -334,7 +335,7 @@ export function TimelinePage(): JSX.Element {
>
{item.stream}
</Badge>
<span>{item.title}</span>
{item.file ? (<FileLink path={item.file} />) : (<span>{item.title}</span>)}
{item.flex && (
<span style={{ color: '#fbbf24', fontSize: '0.65rem', fontWeight: 600 }}>
FLEX

View file

@ -0,0 +1,16 @@
/**
* Normalizes raw content file paths from data (e.g. "../owned-media/blog/foo.md")
* into clean paths usable for routing and API calls.
*/
export function normalizeContentPath(rawPath: string): string {
return rawPath.replace(/^(\.\.\/)+/, '');
}
export function viewerUrl(rawPath: string): string {
return `/view/${normalizeContentPath(rawPath)}`;
}
export function contentApiUrl(path: string): string {
return `/api/content/${path}`;
}