@raystack/chronicle 0.12.2 → 0.12.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -496,6 +496,7 @@ async function createViteConfig(options) {
496
496
  define: {
497
497
  __CHRONICLE_CONTENT_DIR__: JSON.stringify(contentMirror),
498
498
  __CHRONICLE_PROJECT_ROOT__: JSON.stringify(projectRoot),
499
+ __CHRONICLE_PACKAGE_ROOT__: JSON.stringify(packageRoot),
499
500
  __CHRONICLE_CONFIG_RAW__: JSON.stringify(rawConfig)
500
501
  },
501
502
  css: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raystack/chronicle",
3
- "version": "0.12.2",
3
+ "version": "0.12.4",
4
4
  "description": "Config-driven documentation framework",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
Binary file
package/src/lib/head.tsx CHANGED
@@ -9,8 +9,9 @@ export interface HeadProps {
9
9
  markdownHref?: string;
10
10
  }
11
11
 
12
- export function Head({ title, description, config, jsonLd, markdownHref }: HeadProps) {
12
+ export function Head({ title, description: pageDescription, config, jsonLd, markdownHref }: HeadProps) {
13
13
  const { pathname } = useLocation();
14
+ const description = pageDescription || config.site.description;
14
15
  const fullTitle = `${title} | ${config.site.title}`;
15
16
  const ogParams = new URLSearchParams({ title });
16
17
  if (description) ogParams.set('description', description);
@@ -38,7 +38,8 @@ export function DocsPage({ slug }: DocsPageProps) {
38
38
  '@type': 'Article',
39
39
  headline: page.frontmatter.title,
40
40
  description: page.frontmatter.description,
41
- ...(pageUrl && { url: pageUrl })
41
+ ...(pageUrl && { url: pageUrl }),
42
+ ...(page.frontmatter.lastModified && { dateModified: new Date(page.frontmatter.lastModified).toISOString() }),
42
43
  }}
43
44
  />
44
45
  <Page
@@ -1,6 +1,7 @@
1
1
  import { FolderIcon } from '@heroicons/react/24/outline';
2
2
  import { Link as RouterLink } from 'react-router';
3
3
  import { getLandingEntries } from '@/lib/config';
4
+ import { Head } from '@/lib/head';
4
5
  import { usePageContext } from '@/lib/page-context';
5
6
  import styles from './LandingPage.module.css';
6
7
 
@@ -13,6 +14,12 @@ export function LandingPage() {
13
14
  : `${config.site.title} — ${versionLabel(config, version.dir)}`;
14
15
 
15
16
  return (
17
+ <>
18
+ <Head
19
+ title={version.dir ? `${config.site.title} — ${versionLabel(config, version.dir)}` : 'Documentation'}
20
+ description={config.site.description}
21
+ config={config}
22
+ />
16
23
  <div className={styles.root}>
17
24
  <div className={styles.header}>
18
25
  <h1 className={styles.title}>{heading}</h1>
@@ -42,6 +49,7 @@ export function LandingPage() {
42
49
  ))}
43
50
  </div>
44
51
  </div>
52
+ </>
45
53
  );
46
54
  }
47
55
 
@@ -4,7 +4,6 @@ import { ThemeProvider, Skeleton, Flex } from '@raystack/apsara';
4
4
  import { lazy, Suspense } from 'react';
5
5
  import { Navigate, useLocation } from 'react-router';
6
6
  import { AnalyticsProvider } from '@/components/analytics/AnalyticsProvider';
7
- import { Head } from '@/lib/head';
8
7
  import { usePageContext } from '@/lib/page-context';
9
8
  import { resolveRoute, RouteType } from '@/lib/route-resolver';
10
9
  import type { ChronicleConfig } from '@/types';
@@ -69,22 +68,25 @@ function PageFallback() {
69
68
  }
70
69
 
71
70
  function RootHead({ config }: { config: ChronicleConfig }) {
72
- return (
73
- <Head
74
- title={config.site.title}
75
- description={config.site.description}
76
- config={config}
77
- jsonLd={
78
- config.url
79
- ? {
80
- '@context': 'https://schema.org',
81
- '@type': 'WebSite',
82
- name: config.site.title,
83
- description: config.site.description,
84
- url: config.url
85
- }
86
- : undefined
71
+ const siteJsonLd = config.url
72
+ ? {
73
+ '@context': 'https://schema.org',
74
+ '@type': 'WebSite',
75
+ name: config.site.title,
76
+ description: config.site.description,
77
+ url: config.url,
87
78
  }
88
- />
79
+ : null;
80
+
81
+ return (
82
+ <>
83
+ <title>{config.site.title}</title>
84
+ {siteJsonLd && (
85
+ <script
86
+ type='application/ld+json'
87
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(siteJsonLd, null, 2) }}
88
+ />
89
+ )}
90
+ </>
89
91
  );
90
92
  }
@@ -158,6 +158,16 @@ export default {
158
158
  const href = isLocalImage(src) && !isSvg(src) ? buildOptimizedUrl(src, DEFAULT_WIDTH) : src;
159
159
  return <link key={src} rel="preload" as="image" href={href} />;
160
160
  })}
161
+ {isApiRoute && (
162
+ <link
163
+ rel="preload"
164
+ as="fetch"
165
+ crossOrigin="anonymous"
166
+ href={route.version.dir
167
+ ? `/api/specs?version=${encodeURIComponent(route.version.dir)}`
168
+ : '/api/specs'}
169
+ />
170
+ )}
161
171
  <script type="module" src={assets.entry} />
162
172
  <script dangerouslySetInnerHTML={{ __html: `window.__PAGE_DATA__ = ${safeJson}` }} />
163
173
  </head>
@@ -0,0 +1,32 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ const MIME_MAP: Record<string, string> = {
5
+ '.svg': 'image/svg+xml',
6
+ '.png': 'image/png',
7
+ '.jpg': 'image/jpeg',
8
+ '.jpeg': 'image/jpeg',
9
+ };
10
+
11
+ export function getLogoDataUri(data: Buffer, filePath: string): string | null {
12
+ const ext = path.extname(filePath).toLowerCase();
13
+ const mime = MIME_MAP[ext];
14
+ if (!mime) return null;
15
+ return `data:${mime};base64,${data.toString('base64')}`;
16
+ }
17
+
18
+ export async function loadLogo(projectRoot: string, logoPath: string): Promise<string | null> {
19
+ try {
20
+ const filePath = path.resolve(projectRoot, 'public', logoPath.replace(/^\//, ''));
21
+ const data = await fs.readFile(filePath);
22
+ return getLogoDataUri(data, filePath);
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ export async function loadFont(packageRoot: string): Promise<ArrayBuffer> {
29
+ const fontPath = path.resolve(packageRoot, 'src/fonts/Inter-Regular.ttf');
30
+ const buffer = await fs.readFile(fontPath);
31
+ return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
32
+ }
@@ -0,0 +1,63 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import path from 'node:path';
3
+ import { getLogoDataUri, loadLogo, loadFont } from './og-utils';
4
+
5
+ const PACKAGE_ROOT = path.resolve(__dirname, '../../..');
6
+ const FIXTURES = path.resolve(__dirname, '__fixtures__');
7
+
8
+ describe('getLogoDataUri', () => {
9
+ test('svg file returns svg mime type', () => {
10
+ const data = Buffer.from('<svg></svg>');
11
+ const result = getLogoDataUri(data, '/logo.svg');
12
+ expect(result).toStartWith('data:image/svg+xml;base64,');
13
+ });
14
+
15
+ test('png file returns png mime type', () => {
16
+ const data = Buffer.from('fake-png');
17
+ const result = getLogoDataUri(data, '/logo.png');
18
+ expect(result).toStartWith('data:image/png;base64,');
19
+ });
20
+
21
+ test('jpg file returns jpeg mime type', () => {
22
+ const data = Buffer.from('fake-jpg');
23
+ const result = getLogoDataUri(data, '/photo.jpg');
24
+ expect(result).toStartWith('data:image/jpeg;base64,');
25
+ });
26
+
27
+ test('returns null for unsupported format', () => {
28
+ const data = Buffer.from('fake-webp');
29
+ const result = getLogoDataUri(data, '/logo.webp');
30
+ expect(result).toBeNull();
31
+ });
32
+
33
+ test('encodes data as base64', () => {
34
+ const content = '<svg xmlns="http://www.w3.org/2000/svg"></svg>';
35
+ const data = Buffer.from(content);
36
+ const result = getLogoDataUri(data, '/icon.svg');
37
+ const base64 = result!.split(',')[1];
38
+ expect(Buffer.from(base64, 'base64').toString()).toBe(content);
39
+ });
40
+ });
41
+
42
+ describe('loadLogo', () => {
43
+ test('returns null for nonexistent file', async () => {
44
+ const result = await loadLogo('/nonexistent', '/logo.svg');
45
+ expect(result).toBeNull();
46
+ });
47
+
48
+ test('strips leading slash from logo path', async () => {
49
+ const result = await loadLogo('/nonexistent', '/nested/logo.svg');
50
+ expect(result).toBeNull();
51
+ });
52
+ });
53
+
54
+ describe('loadFont', () => {
55
+ test('loads Inter font from package', async () => {
56
+ const font = await loadFont(PACKAGE_ROOT);
57
+ expect(font.byteLength).toBeGreaterThan(0);
58
+ });
59
+
60
+ test('throws for invalid path', async () => {
61
+ expect(loadFont('/nonexistent')).rejects.toThrow();
62
+ });
63
+ });
@@ -2,23 +2,10 @@ import { defineHandler } from 'nitro';
2
2
  import React from 'react';
3
3
  import satori from 'satori';
4
4
  import { loadConfig } from '@/lib/config';
5
+ import { loadFont, loadLogo } from './og-utils';
5
6
 
6
7
  let fontData: ArrayBuffer | null = null;
7
-
8
- async function loadFont(): Promise<ArrayBuffer> {
9
- if (fontData) return fontData;
10
-
11
- try {
12
- const response = await fetch(
13
- 'https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hiA.woff2'
14
- );
15
- fontData = await response.arrayBuffer();
16
- } catch {
17
- fontData = new ArrayBuffer(0);
18
- }
19
-
20
- return fontData;
21
- }
8
+ let cachedLogo: string | null | undefined;
22
9
 
23
10
  export default defineHandler(async event => {
24
11
  const config = loadConfig();
@@ -26,7 +13,12 @@ export default defineHandler(async event => {
26
13
  const description = event.url.searchParams.get('description') ?? '';
27
14
  const siteName = config.site.title;
28
15
 
29
- const font = await loadFont();
16
+ if (!fontData) fontData = await loadFont(__CHRONICLE_PACKAGE_ROOT__);
17
+ if (cachedLogo === undefined) {
18
+ cachedLogo = config.logo?.dark
19
+ ? await loadLogo(__CHRONICLE_PROJECT_ROOT__, config.logo.dark)
20
+ : null;
21
+ }
30
22
 
31
23
  const svg = await satori(
32
24
  <div
@@ -41,8 +33,18 @@ export default defineHandler(async event => {
41
33
  color: '#fafafa',
42
34
  }}
43
35
  >
44
- <div style={{ fontSize: 24, color: '#888', marginBottom: 16 }}>
45
- {siteName}
36
+ <div style={{ display: 'flex', alignItems: 'center', marginBottom: 16 }}>
37
+ {cachedLogo && (
38
+ <img
39
+ src={cachedLogo}
40
+ width={48}
41
+ height={48}
42
+ style={{ marginRight: 16 }}
43
+ />
44
+ )}
45
+ <div style={{ fontSize: 32, color: '#888' }}>
46
+ {siteName}
47
+ </div>
46
48
  </div>
47
49
  <div
48
50
  style={{
@@ -64,7 +66,7 @@ export default defineHandler(async event => {
64
66
  width: 1200,
65
67
  height: 630,
66
68
  fonts: [
67
- { name: 'Inter', data: font, weight: 400, style: 'normal' as const },
69
+ { name: 'Inter', data: fontData, weight: 400, style: 'normal' as const },
68
70
  ],
69
71
  },
70
72
  );
@@ -131,6 +131,7 @@ export async function createViteConfig(
131
131
  define: {
132
132
  __CHRONICLE_CONTENT_DIR__: JSON.stringify(contentMirror),
133
133
  __CHRONICLE_PROJECT_ROOT__: JSON.stringify(projectRoot),
134
+ __CHRONICLE_PACKAGE_ROOT__: JSON.stringify(packageRoot),
134
135
  __CHRONICLE_CONFIG_RAW__: JSON.stringify(rawConfig),
135
136
  },
136
137
  css: {
@@ -1,4 +1,5 @@
1
1
  // Vite build-time constants (injected via define in vite-config.ts)
2
2
  declare const __CHRONICLE_CONTENT_DIR__: string
3
3
  declare const __CHRONICLE_PROJECT_ROOT__: string
4
+ declare const __CHRONICLE_PACKAGE_ROOT__: string
4
5
  declare const __CHRONICLE_CONFIG_RAW__: string | null