mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-12 19:55:19 +02:00
iframe initial commit
This commit is contained in:
parent
7dbfcb72f4
commit
a5fc7faa9b
8 changed files with 1229 additions and 1 deletions
|
|
@ -25,6 +25,7 @@ import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
|
|||
import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js";
|
||||
import { init as initTrackScheduler } from "@x/core/dist/knowledge/track/scheduler.js";
|
||||
import { init as initTrackEventProcessor } from "@x/core/dist/knowledge/track/events.js";
|
||||
import { init as initLocalSites } from "@x/core/dist/local-sites/server.js";
|
||||
|
||||
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
|
||||
import started from "electron-squirrel-startup";
|
||||
|
|
@ -291,6 +292,11 @@ app.whenReady().then(async () => {
|
|||
// start chrome extension sync server
|
||||
initChromeSync();
|
||||
|
||||
// start local sites server for iframe dashboards and other mini apps
|
||||
initLocalSites().catch((error) => {
|
||||
console.error('[LocalSites] Failed to start:', error);
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { TrackBlockExtension } from '@/extensions/track-block'
|
|||
import { TrackTargetOpenExtension, TrackTargetCloseExtension } from '@/extensions/track-target'
|
||||
import { ImageBlockExtension } from '@/extensions/image-block'
|
||||
import { EmbedBlockExtension } from '@/extensions/embed-block'
|
||||
import { IframeBlockExtension } from '@/extensions/iframe-block'
|
||||
import { ChartBlockExtension } from '@/extensions/chart-block'
|
||||
import { TableBlockExtension } from '@/extensions/table-block'
|
||||
import { CalendarBlockExtension } from '@/extensions/calendar-block'
|
||||
|
|
@ -177,6 +178,8 @@ function blockToMarkdown(node: JsonNode): string {
|
|||
return '```image\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'embedBlock':
|
||||
return '```embed\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'iframeBlock':
|
||||
return '```iframe\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'chartBlock':
|
||||
return '```chart\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'tableBlock':
|
||||
|
|
@ -676,6 +679,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
TrackTargetCloseExtension,
|
||||
ImageBlockExtension,
|
||||
EmbedBlockExtension,
|
||||
IframeBlockExtension,
|
||||
ChartBlockExtension,
|
||||
TableBlockExtension,
|
||||
CalendarBlockExtension,
|
||||
|
|
|
|||
165
apps/x/apps/renderer/src/extensions/iframe-block.tsx
Normal file
165
apps/x/apps/renderer/src/extensions/iframe-block.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { ExternalLink, Globe, X } from 'lucide-react'
|
||||
import { blocks } from '@x/shared'
|
||||
|
||||
const DEFAULT_IFRAME_HEIGHT = 560
|
||||
const DEFAULT_IFRAME_ALLOW = [
|
||||
'accelerometer',
|
||||
'autoplay',
|
||||
'camera',
|
||||
'clipboard-read',
|
||||
'clipboard-write',
|
||||
'display-capture',
|
||||
'encrypted-media',
|
||||
'fullscreen',
|
||||
'geolocation',
|
||||
'microphone',
|
||||
].join('; ')
|
||||
|
||||
function getIframeMeta(url: string): { host: string; path: string } | null {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return {
|
||||
host: parsed.host,
|
||||
path: parsed.pathname === '/' ? '' : parsed.pathname,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function IframeBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
|
||||
const raw = node.attrs.data as string
|
||||
let config: blocks.IframeBlock | null = null
|
||||
|
||||
try {
|
||||
config = blocks.IframeBlockSchema.parse(JSON.parse(raw))
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<NodeViewWrapper className="iframe-block-wrapper" data-type="iframe-block">
|
||||
<div className="iframe-block-card iframe-block-error">
|
||||
<Globe size={16} />
|
||||
<span>Invalid iframe block</span>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = getIframeMeta(config.url)
|
||||
const title = config.title || meta?.host || 'Embedded page'
|
||||
const allow = config.allow || DEFAULT_IFRAME_ALLOW
|
||||
const height = config.height ?? DEFAULT_IFRAME_HEIGHT
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="iframe-block-wrapper" data-type="iframe-block">
|
||||
<div className="iframe-block-card">
|
||||
<button
|
||||
className="iframe-block-delete"
|
||||
onClick={deleteNode}
|
||||
aria-label="Delete iframe block"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<div className="iframe-block-header">
|
||||
<div className="iframe-block-header-main">
|
||||
<div className="iframe-block-badge">
|
||||
<Globe size={13} />
|
||||
Iframe
|
||||
</div>
|
||||
<div className="iframe-block-title-row">
|
||||
<div className="iframe-block-title">{title}</div>
|
||||
{meta && (
|
||||
<div className="iframe-block-host">
|
||||
{meta.host}
|
||||
{meta.path}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={config.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="iframe-block-open"
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
Open
|
||||
</a>
|
||||
</div>
|
||||
<div className="iframe-block-frame-shell" style={{ height }}>
|
||||
<iframe
|
||||
src={config.url}
|
||||
title={title}
|
||||
className="iframe-block-frame"
|
||||
loading="lazy"
|
||||
allow={allow}
|
||||
allowFullScreen
|
||||
sandbox="allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms allow-modals allow-downloads"
|
||||
/>
|
||||
</div>
|
||||
{config.caption && (
|
||||
<div className="iframe-block-caption">{config.caption}</div>
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const IframeBlockExtension = Node.create({
|
||||
name: 'iframeBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
data: {
|
||||
default: '{}',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'pre',
|
||||
priority: 60,
|
||||
getAttrs(element) {
|
||||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
const cls = code.className || ''
|
||||
if (cls.includes('language-iframe')) {
|
||||
return { data: code.textContent || '{}' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'iframe-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(IframeBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```iframe\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -764,6 +764,7 @@
|
|||
/* Shared block styles (image, embed, chart, table) */
|
||||
.tiptap-editor .ProseMirror .image-block-wrapper,
|
||||
.tiptap-editor .ProseMirror .embed-block-wrapper,
|
||||
.tiptap-editor .ProseMirror .iframe-block-wrapper,
|
||||
.tiptap-editor .ProseMirror .chart-block-wrapper,
|
||||
.tiptap-editor .ProseMirror .table-block-wrapper,
|
||||
.tiptap-editor .ProseMirror .calendar-block-wrapper,
|
||||
|
|
@ -775,6 +776,7 @@
|
|||
|
||||
.tiptap-editor .ProseMirror .image-block-card,
|
||||
.tiptap-editor .ProseMirror .embed-block-card,
|
||||
.tiptap-editor .ProseMirror .iframe-block-card,
|
||||
.tiptap-editor .ProseMirror .chart-block-card,
|
||||
.tiptap-editor .ProseMirror .table-block-card,
|
||||
.tiptap-editor .ProseMirror .calendar-block-card,
|
||||
|
|
@ -793,6 +795,7 @@
|
|||
|
||||
.tiptap-editor .ProseMirror .image-block-card:hover,
|
||||
.tiptap-editor .ProseMirror .embed-block-card:hover,
|
||||
.tiptap-editor .ProseMirror .iframe-block-card:hover,
|
||||
.tiptap-editor .ProseMirror .chart-block-card:hover,
|
||||
.tiptap-editor .ProseMirror .table-block-card:hover,
|
||||
.tiptap-editor .ProseMirror .calendar-block-card:hover,
|
||||
|
|
@ -805,6 +808,7 @@
|
|||
|
||||
.tiptap-editor .ProseMirror .image-block-wrapper.ProseMirror-selectednode .image-block-card,
|
||||
.tiptap-editor .ProseMirror .embed-block-wrapper.ProseMirror-selectednode .embed-block-card,
|
||||
.tiptap-editor .ProseMirror .iframe-block-wrapper.ProseMirror-selectednode .iframe-block-card,
|
||||
.tiptap-editor .ProseMirror .chart-block-wrapper.ProseMirror-selectednode .chart-block-card,
|
||||
.tiptap-editor .ProseMirror .table-block-wrapper.ProseMirror-selectednode .table-block-card,
|
||||
.tiptap-editor .ProseMirror .calendar-block-wrapper.ProseMirror-selectednode .calendar-block-card,
|
||||
|
|
@ -817,6 +821,7 @@
|
|||
|
||||
.tiptap-editor .ProseMirror .image-block-delete,
|
||||
.tiptap-editor .ProseMirror .embed-block-delete,
|
||||
.tiptap-editor .ProseMirror .iframe-block-delete,
|
||||
.tiptap-editor .ProseMirror .chart-block-delete,
|
||||
.tiptap-editor .ProseMirror .table-block-delete,
|
||||
.tiptap-editor .ProseMirror .calendar-block-delete,
|
||||
|
|
@ -843,6 +848,7 @@
|
|||
|
||||
.tiptap-editor .ProseMirror .image-block-card:hover .image-block-delete,
|
||||
.tiptap-editor .ProseMirror .embed-block-card:hover .embed-block-delete,
|
||||
.tiptap-editor .ProseMirror .iframe-block-card:hover .iframe-block-delete,
|
||||
.tiptap-editor .ProseMirror .chart-block-card:hover .chart-block-delete,
|
||||
.tiptap-editor .ProseMirror .table-block-card:hover .table-block-delete,
|
||||
.tiptap-editor .ProseMirror .calendar-block-card:hover .calendar-block-delete,
|
||||
|
|
@ -854,6 +860,7 @@
|
|||
|
||||
.tiptap-editor .ProseMirror .image-block-delete:hover,
|
||||
.tiptap-editor .ProseMirror .embed-block-delete:hover,
|
||||
.tiptap-editor .ProseMirror .iframe-block-delete:hover,
|
||||
.tiptap-editor .ProseMirror .chart-block-delete:hover,
|
||||
.tiptap-editor .ProseMirror .table-block-delete:hover,
|
||||
.tiptap-editor .ProseMirror .calendar-block-delete:hover,
|
||||
|
|
@ -943,6 +950,112 @@
|
|||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Iframe block */
|
||||
.tiptap-editor .ProseMirror .iframe-block-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .iframe-block-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .iframe-block-header-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .iframe-block-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: fit-content;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
color: color-mix(in srgb, var(--foreground) 78%, transparent);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .iframe-block-title-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .iframe-block-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .iframe-block-host {
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .iframe-block-open {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
color: var(--foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .iframe-block-open:hover {
|
||||
background: color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .iframe-block-frame-shell {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 240px;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at top left, color-mix(in srgb, var(--primary) 14%, transparent), transparent 45%),
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--muted) 65%, transparent), color-mix(in srgb, var(--background) 95%, transparent));
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .iframe-block-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .iframe-block-caption {
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .iframe-block-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Chart block */
|
||||
.tiptap-editor .ProseMirror .chart-block-title {
|
||||
font-size: 14px;
|
||||
|
|
|
|||
|
|
@ -196,6 +196,18 @@ Embeds external content (YouTube videos, Figma designs, or generic links).
|
|||
- \`caption\` (optional): Caption displayed below the embed
|
||||
- YouTube and Figma render as iframes; generic shows a link card
|
||||
|
||||
### Iframe Block
|
||||
Embeds an arbitrary web page or a locally-served dashboard in the note.
|
||||
\`\`\`iframe
|
||||
{"url": "http://localhost:3210/sites/example-dashboard/", "title": "Trend Dashboard", "height": 640, "caption": "Local dashboard served from sites/example-dashboard"}
|
||||
\`\`\`
|
||||
- \`url\` (required): Full URL to render. Use \`https://\` for remote sites, or \`http://localhost:3210/sites/<slug>/\` for local dashboards
|
||||
- \`title\` (optional): Title shown above the iframe
|
||||
- \`height\` (optional): Height in pixels. Good dashboard defaults are 480-800
|
||||
- \`caption\` (optional): Caption shown below the iframe
|
||||
- \`allow\` (optional): Custom iframe \`allow\` attribute when the page needs extra browser capabilities
|
||||
- Remote sites may refuse to render in iframes because of their own CSP / X-Frame-Options headers. When you need a reliable embed, create a local site in \`sites/<slug>/\` and use the localhost URL above
|
||||
|
||||
### Chart Block
|
||||
Renders a chart from inline data.
|
||||
\`\`\`chart
|
||||
|
|
@ -220,8 +232,9 @@ Renders a styled table from structured data.
|
|||
### Block Guidelines
|
||||
- The JSON must be valid and on a single line (no pretty-printing)
|
||||
- Insert blocks using \`workspace-editFile\` just like any other content
|
||||
- When the user asks for a chart, table, or embed — use blocks rather than plain Markdown tables or image links
|
||||
- When the user asks for a chart, table, embed, or live dashboard — use blocks rather than plain Markdown tables or image links
|
||||
- When editing a note that already contains blocks, preserve them unless the user asks to change them
|
||||
- For local dashboards and mini apps, put the site files in \`sites/<slug>/\` and point an \`iframe\` block at \`http://localhost:3210/sites/<slug>/\`
|
||||
|
||||
## Best Practices
|
||||
|
||||
|
|
|
|||
278
apps/x/packages/core/src/local-sites/server.ts
Normal file
278
apps/x/packages/core/src/local-sites/server.ts
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { Server } from 'node:http';
|
||||
import express from 'express';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { LOCAL_SITE_SCAFFOLD } from './templates.js';
|
||||
|
||||
export const LOCAL_SITES_PORT = 3210;
|
||||
export const LOCAL_SITES_BASE_URL = `http://localhost:${LOCAL_SITES_PORT}`;
|
||||
|
||||
const LOCAL_SITES_DIR = path.join(WorkDir, 'sites');
|
||||
const SITE_SLUG_RE = /^[a-z0-9][a-z0-9-_]*$/i;
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
'.css',
|
||||
'.html',
|
||||
'.js',
|
||||
'.json',
|
||||
'.map',
|
||||
'.mjs',
|
||||
'.svg',
|
||||
'.txt',
|
||||
'.xml',
|
||||
]);
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.gif': 'image/gif',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.ico': 'image/x-icon',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.js': 'application/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.map': 'application/json; charset=utf-8',
|
||||
'.mjs': 'application/javascript; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml; charset=utf-8',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.wasm': 'application/wasm',
|
||||
'.webp': 'image/webp',
|
||||
'.xml': 'application/xml; charset=utf-8',
|
||||
};
|
||||
|
||||
let localSitesServer: Server | null = null;
|
||||
let startPromise: Promise<void> | null = null;
|
||||
|
||||
function isSafeSiteSlug(siteSlug: string): boolean {
|
||||
return SITE_SLUG_RE.test(siteSlug);
|
||||
}
|
||||
|
||||
function resolveSiteDir(siteSlug: string): string | null {
|
||||
if (!isSafeSiteSlug(siteSlug)) return null;
|
||||
return path.join(LOCAL_SITES_DIR, siteSlug);
|
||||
}
|
||||
|
||||
function resolveRequestedPath(siteDir: string, requestPath: string): string | null {
|
||||
const candidate = requestPath === '/' ? '/index.html' : requestPath;
|
||||
const normalized = path.posix.normalize(candidate);
|
||||
const relativePath = normalized.replace(/^\/+/, '');
|
||||
|
||||
if (!relativePath || relativePath === '.' || relativePath.startsWith('..') || relativePath.includes('\0')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const absolutePath = path.resolve(siteDir, relativePath);
|
||||
if (!absolutePath.startsWith(siteDir + path.sep) && absolutePath !== siteDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return absolutePath;
|
||||
}
|
||||
|
||||
function getRequestPath(req: express.Request): string {
|
||||
const rawPath = req.url.split('?')[0] || '/';
|
||||
return rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
|
||||
}
|
||||
|
||||
function listLocalSites(): Array<{ slug: string; url: string }> {
|
||||
if (!fs.existsSync(LOCAL_SITES_DIR)) return [];
|
||||
|
||||
return fs.readdirSync(LOCAL_SITES_DIR, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && isSafeSiteSlug(entry.name))
|
||||
.map((entry) => ({
|
||||
slug: entry.name,
|
||||
url: `${LOCAL_SITES_BASE_URL}/sites/${entry.name}/`,
|
||||
}))
|
||||
.sort((a, b) => a.slug.localeCompare(b.slug));
|
||||
}
|
||||
|
||||
function isPathInsideRoot(rootPath: string, candidatePath: string): boolean {
|
||||
return candidatePath === rootPath || candidatePath.startsWith(rootPath + path.sep);
|
||||
}
|
||||
|
||||
async function writeIfMissing(filePath: string, content: string): Promise<void> {
|
||||
try {
|
||||
await fsp.access(filePath);
|
||||
} catch {
|
||||
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fsp.writeFile(filePath, content, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureLocalSiteScaffold(): Promise<void> {
|
||||
await fsp.mkdir(LOCAL_SITES_DIR, { recursive: true });
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(LOCAL_SITE_SCAFFOLD).map(([relativePath, content]) =>
|
||||
writeIfMissing(path.join(LOCAL_SITES_DIR, relativePath), content),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function respondWithFile(res: express.Response, filePath: string, method: string): Promise<void> {
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
const mimeType = MIME_TYPES[extension] || 'application/octet-stream';
|
||||
const stats = await fsp.stat(filePath);
|
||||
|
||||
res.status(200);
|
||||
res.setHeader('Content-Type', mimeType);
|
||||
res.setHeader('Content-Length', String(stats.size));
|
||||
res.setHeader('Cache-Control', extension === '.html' ? 'no-cache' : 'public, max-age=60');
|
||||
|
||||
if (method === 'HEAD') {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (TEXT_EXTENSIONS.has(extension)) {
|
||||
const text = await fsp.readFile(filePath, 'utf8');
|
||||
res.end(text);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await fsp.readFile(filePath);
|
||||
res.end(data);
|
||||
}
|
||||
|
||||
async function sendSiteResponse(req: express.Request, res: express.Response): Promise<void> {
|
||||
const siteSlugParam = req.params.siteSlug;
|
||||
const siteSlug = Array.isArray(siteSlugParam) ? siteSlugParam[0] : siteSlugParam;
|
||||
const siteDir = siteSlug ? resolveSiteDir(siteSlug) : null;
|
||||
if (!siteDir) {
|
||||
res.status(400).json({ error: 'Invalid site slug' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(siteDir) || !fs.statSync(siteDir).isDirectory()) {
|
||||
res.status(404).json({ error: 'Site not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const realSitesDir = fs.realpathSync(LOCAL_SITES_DIR);
|
||||
const realSiteDir = fs.realpathSync(siteDir);
|
||||
if (!isPathInsideRoot(realSitesDir, realSiteDir)) {
|
||||
res.status(403).json({ error: 'Site path escapes sites directory' });
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedPath = resolveRequestedPath(siteDir, getRequestPath(req));
|
||||
if (!requestedPath) {
|
||||
res.status(400).json({ error: 'Invalid site path' });
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedExt = path.extname(requestedPath);
|
||||
if (fs.existsSync(requestedPath)) {
|
||||
const stat = fs.statSync(requestedPath);
|
||||
if (stat.isDirectory()) {
|
||||
const indexPath = path.join(requestedPath, 'index.html');
|
||||
if (fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) {
|
||||
const realIndexPath = fs.realpathSync(indexPath);
|
||||
if (!isPathInsideRoot(realSiteDir, realIndexPath)) {
|
||||
res.status(403).json({ error: 'Site path escapes root' });
|
||||
return;
|
||||
}
|
||||
await respondWithFile(res, indexPath, req.method);
|
||||
return;
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
const realRequestedPath = fs.realpathSync(requestedPath);
|
||||
if (!isPathInsideRoot(realSiteDir, realRequestedPath)) {
|
||||
res.status(403).json({ error: 'Site path escapes root' });
|
||||
return;
|
||||
}
|
||||
await respondWithFile(res, requestedPath, req.method);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (requestedExt) {
|
||||
res.status(404).json({ error: 'Asset not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const spaFallback = path.join(siteDir, 'index.html');
|
||||
if (!fs.existsSync(spaFallback) || !fs.statSync(spaFallback).isFile()) {
|
||||
res.status(404).json({ error: 'Site entrypoint not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const realFallback = fs.realpathSync(spaFallback);
|
||||
if (!isPathInsideRoot(realSiteDir, realFallback)) {
|
||||
res.status(403).json({ error: 'Site path escapes root' });
|
||||
return;
|
||||
}
|
||||
|
||||
await respondWithFile(res, spaFallback, req.method);
|
||||
}
|
||||
|
||||
function createLocalSitesApp(): express.Express {
|
||||
const app = express();
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
baseUrl: LOCAL_SITES_BASE_URL,
|
||||
sitesDir: LOCAL_SITES_DIR,
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/sites', (_req, res) => {
|
||||
res.json({
|
||||
sites: listLocalSites(),
|
||||
});
|
||||
});
|
||||
|
||||
app.use('/sites/:siteSlug', (req, res) => {
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
res.status(405).json({ error: 'Method not allowed' });
|
||||
return;
|
||||
}
|
||||
|
||||
void sendSiteResponse(req, res).catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
async function startServer(): Promise<void> {
|
||||
if (localSitesServer) return;
|
||||
|
||||
const app = createLocalSitesApp();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const server = app.listen(LOCAL_SITES_PORT, 'localhost', () => {
|
||||
localSitesServer = server;
|
||||
console.log('[LocalSites] Server starting.');
|
||||
console.log(` Sites directory: ${LOCAL_SITES_DIR}`);
|
||||
console.log(` Base URL: ${LOCAL_SITES_BASE_URL}`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
server.on('error', (error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'EADDRINUSE') {
|
||||
reject(new Error(`Port ${LOCAL_SITES_PORT} is already in use.`));
|
||||
return;
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function init(): Promise<void> {
|
||||
if (localSitesServer) return;
|
||||
if (startPromise) return startPromise;
|
||||
|
||||
startPromise = (async () => {
|
||||
await ensureLocalSiteScaffold();
|
||||
await startServer();
|
||||
})().finally(() => {
|
||||
startPromise = null;
|
||||
});
|
||||
|
||||
return startPromise;
|
||||
}
|
||||
624
apps/x/packages/core/src/local-sites/templates.ts
Normal file
624
apps/x/packages/core/src/local-sites/templates.ts
Normal file
|
|
@ -0,0 +1,624 @@
|
|||
export const LOCAL_SITE_SCAFFOLD: Record<string, string> = {
|
||||
'README.md': `# Local Sites
|
||||
|
||||
Anything inside this folder is available at:
|
||||
|
||||
\`http://localhost:3210/sites/<slug>/\`
|
||||
|
||||
Examples:
|
||||
|
||||
- \`sites/example-dashboard/\` -> \`http://localhost:3210/sites/example-dashboard/\`
|
||||
- \`sites/team-ops/\` -> \`http://localhost:3210/sites/team-ops/\`
|
||||
|
||||
You can embed a local site in a note with:
|
||||
|
||||
\`\`\`iframe
|
||||
{"url":"http://localhost:3210/sites/example-dashboard/","title":"Signal Deck","height":640,"caption":"Local dashboard served from sites/example-dashboard"}
|
||||
\`\`\`
|
||||
|
||||
Notes:
|
||||
|
||||
- The app serves each site with SPA-friendly routing, so client-side routers work
|
||||
- Put an \`index.html\` file at the site root
|
||||
- Remote APIs still need to allow browser requests from a local page
|
||||
`,
|
||||
'example-dashboard/index.html': `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Signal Deck</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="ambient ambient-one"></div>
|
||||
<div class="ambient ambient-two"></div>
|
||||
<main class="shell">
|
||||
<header class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">Local iframe sample · external APIs</p>
|
||||
<h1>Signal Deck</h1>
|
||||
<p class="lede">
|
||||
A locally-served dashboard designed to live inside a Rowboat note. It fetches
|
||||
live signals from public APIs and stays readable at note width.
|
||||
</p>
|
||||
</div>
|
||||
<div class="hero-status" id="hero-status">Booting dashboard...</div>
|
||||
</header>
|
||||
|
||||
<section class="metric-grid" id="metric-grid"></section>
|
||||
|
||||
<section class="board">
|
||||
<article class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="panel-kicker">Hacker News</p>
|
||||
<h2>Live headlines</h2>
|
||||
</div>
|
||||
<span class="panel-chip">public API</span>
|
||||
</div>
|
||||
<div class="story-list" id="story-list"></div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="panel-kicker">GitHub</p>
|
||||
<h2>Repo pulse</h2>
|
||||
</div>
|
||||
<span class="panel-chip">public API</span>
|
||||
</div>
|
||||
<div class="repo-list" id="repo-list"></div>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script type="module" src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
'example-dashboard/styles.css': `:root {
|
||||
color-scheme: dark;
|
||||
--bg: #090816;
|
||||
--panel: rgba(18, 16, 39, 0.88);
|
||||
--panel-strong: rgba(26, 23, 54, 0.96);
|
||||
--line: rgba(255, 255, 255, 0.08);
|
||||
--text: #f5f7ff;
|
||||
--muted: rgba(230, 235, 255, 0.68);
|
||||
--cyan: #66e2ff;
|
||||
--lime: #b7ff6a;
|
||||
--amber: #ffcb6b;
|
||||
--pink: #ff7ed1;
|
||||
--shadow: 0 24px 80px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(74, 51, 175, 0.28), transparent 34%),
|
||||
linear-gradient(180deg, #0c0b1d 0%, var(--bg) 100%);
|
||||
}
|
||||
|
||||
.ambient {
|
||||
position: fixed;
|
||||
inset: auto;
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
border-radius: 999px;
|
||||
filter: blur(70px);
|
||||
pointer-events: none;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.ambient-one {
|
||||
top: -80px;
|
||||
right: -40px;
|
||||
background: rgba(102, 226, 255, 0.22);
|
||||
}
|
||||
|
||||
.ambient-two {
|
||||
bottom: -120px;
|
||||
left: -60px;
|
||||
background: rgba(255, 126, 209, 0.18);
|
||||
}
|
||||
|
||||
.shell {
|
||||
position: relative;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px 40px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.panel-kicker {
|
||||
margin: 0 0 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 11px;
|
||||
color: var(--cyan);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
|
||||
font-size: clamp(2rem, 5vw, 3.4rem);
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
|
||||
.lede {
|
||||
max-width: 620px;
|
||||
margin-top: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.hero-status {
|
||||
flex-shrink: 0;
|
||||
min-width: 180px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(102, 226, 255, 0.18);
|
||||
border-radius: 16px;
|
||||
background: rgba(14, 17, 32, 0.62);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.metric-card,
|
||||
.panel {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0)),
|
||||
var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
padding: 18px;
|
||||
min-height: 152px;
|
||||
}
|
||||
|
||||
.metric-card::after,
|
||||
.panel::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.07), transparent 40%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
margin-top: 16px;
|
||||
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
|
||||
font-size: clamp(2rem, 4vw, 2.7rem);
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.06em;
|
||||
}
|
||||
|
||||
.metric-detail {
|
||||
margin-top: 12px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.metric-spark {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 1fr;
|
||||
gap: 6px;
|
||||
align-items: end;
|
||||
height: 40px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.metric-spark span {
|
||||
display: block;
|
||||
border-radius: 999px 999px 3px 3px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(255, 255, 255, 0.1));
|
||||
}
|
||||
|
||||
.board {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
|
||||
font-size: 1.3rem;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.panel-chip {
|
||||
padding: 7px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.story-list,
|
||||
.repo-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.story-item,
|
||||
.repo-item {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 18px;
|
||||
background: var(--panel-strong);
|
||||
}
|
||||
|
||||
.story-rank {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
|
||||
font-size: 1.2rem;
|
||||
color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.story-item a,
|
||||
.repo-item a {
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.story-item a:hover,
|
||||
.repo-item a:hover {
|
||||
color: var(--cyan);
|
||||
}
|
||||
|
||||
.story-title,
|
||||
.repo-name {
|
||||
padding-right: 34px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.story-meta,
|
||||
.repo-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.story-pill,
|
||||
.repo-pill {
|
||||
padding: 5px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.repo-description {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 18px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 940px) {
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.board {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.shell {
|
||||
padding: 22px 14px 28px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hero-status {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.panel,
|
||||
.metric-card {
|
||||
border-radius: 18px;
|
||||
}
|
||||
}
|
||||
`,
|
||||
'example-dashboard/app.js': `const formatter = new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
const reposConfig = [
|
||||
{
|
||||
slug: 'rowboatlabs/rowboat',
|
||||
label: 'Rowboat',
|
||||
description: 'AI coworker with memory',
|
||||
},
|
||||
{
|
||||
slug: 'openai/openai-cookbook',
|
||||
label: 'OpenAI Cookbook',
|
||||
description: 'Examples and guides for building with OpenAI APIs',
|
||||
},
|
||||
];
|
||||
|
||||
const fallbackStories = [
|
||||
{ id: 1, title: 'AI product launches keep getting more opinionated', score: 182, descendants: 49, by: 'analyst', url: '#' },
|
||||
{ id: 2, title: 'Designing dashboards that can survive a narrow iframe', score: 141, descendants: 26, by: 'maker', url: '#' },
|
||||
{ id: 3, title: 'Why local mini-apps inside notes are underrated', score: 119, descendants: 18, by: 'builder', url: '#' },
|
||||
{ id: 4, title: 'Teams want live data in docs, not screenshots', score: 97, descendants: 14, by: 'operator', url: '#' },
|
||||
];
|
||||
|
||||
const fallbackRepos = [
|
||||
{ ...reposConfig[0], stars: 1280, forks: 144, issues: 28, url: 'https://github.com/rowboatlabs/rowboat' },
|
||||
{ ...reposConfig[1], stars: 71600, forks: 11300, issues: 52, url: 'https://github.com/openai/openai-cookbook' },
|
||||
];
|
||||
|
||||
const metricGrid = document.getElementById('metric-grid');
|
||||
const storyList = document.getElementById('story-list');
|
||||
const repoList = document.getElementById('repo-list');
|
||||
const heroStatus = document.getElementById('hero-status');
|
||||
|
||||
async function fetchJson(url) {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Request failed with status ' + response.status);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function loadRepos() {
|
||||
try {
|
||||
const repos = await Promise.all(
|
||||
reposConfig.map(async (repo) => {
|
||||
const data = await fetchJson('https://api.github.com/repos/' + repo.slug);
|
||||
return {
|
||||
...repo,
|
||||
stars: data.stargazers_count,
|
||||
forks: data.forks_count,
|
||||
issues: data.open_issues_count,
|
||||
url: data.html_url,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return repos;
|
||||
} catch {
|
||||
return fallbackRepos;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStories() {
|
||||
try {
|
||||
const ids = await fetchJson('https://hacker-news.firebaseio.com/v0/topstories.json');
|
||||
const stories = await Promise.all(
|
||||
ids.slice(0, 4).map((id) =>
|
||||
fetchJson('https://hacker-news.firebaseio.com/v0/item/' + id + '.json'),
|
||||
),
|
||||
);
|
||||
|
||||
return stories
|
||||
.filter(Boolean)
|
||||
.map((story) => ({
|
||||
id: story.id,
|
||||
title: story.title,
|
||||
score: story.score || 0,
|
||||
descendants: story.descendants || 0,
|
||||
by: story.by || 'unknown',
|
||||
url: story.url || ('https://news.ycombinator.com/item?id=' + story.id),
|
||||
}));
|
||||
} catch {
|
||||
return fallbackStories;
|
||||
}
|
||||
}
|
||||
|
||||
function metricSpark(values) {
|
||||
const max = Math.max(...values, 1);
|
||||
const bars = values.map((value) => {
|
||||
const height = Math.max(18, Math.round((value / max) * 40));
|
||||
return '<span style="height:' + height + 'px"></span>';
|
||||
});
|
||||
return '<div class="metric-spark">' + bars.join('') + '</div>';
|
||||
}
|
||||
|
||||
function renderMetrics(repos, stories) {
|
||||
const leadRepo = repos[0];
|
||||
const companionRepo = repos[1];
|
||||
const topStory = stories[0];
|
||||
const averageScore = Math.round(
|
||||
stories.reduce((sum, story) => sum + story.score, 0) / Math.max(stories.length, 1),
|
||||
);
|
||||
|
||||
const metrics = [
|
||||
{
|
||||
label: 'Rowboat stars',
|
||||
value: formatter.format(leadRepo.stars),
|
||||
detail: formatter.format(leadRepo.forks) + ' forks · ' + leadRepo.issues + ' open issues',
|
||||
spark: [leadRepo.stars * 0.58, leadRepo.stars * 0.71, leadRepo.stars * 0.88, leadRepo.stars],
|
||||
accent: 'var(--cyan)',
|
||||
},
|
||||
{
|
||||
label: 'Cookbook stars',
|
||||
value: formatter.format(companionRepo.stars),
|
||||
detail: formatter.format(companionRepo.forks) + ' forks · ' + companionRepo.issues + ' open issues',
|
||||
spark: [companionRepo.stars * 0.76, companionRepo.stars * 0.81, companionRepo.stars * 0.93, companionRepo.stars],
|
||||
accent: 'var(--lime)',
|
||||
},
|
||||
{
|
||||
label: 'Top story score',
|
||||
value: formatter.format(topStory.score),
|
||||
detail: topStory.descendants + ' comments · by ' + topStory.by,
|
||||
spark: stories.map((story) => story.score),
|
||||
accent: 'var(--amber)',
|
||||
},
|
||||
{
|
||||
label: 'Average HN score',
|
||||
value: formatter.format(averageScore),
|
||||
detail: stories.length + ' live stories in this panel',
|
||||
spark: stories.map((story) => story.descendants + 10),
|
||||
accent: 'var(--pink)',
|
||||
},
|
||||
];
|
||||
|
||||
metricGrid.innerHTML = metrics
|
||||
.map((metric) => (
|
||||
'<article class="metric-card" style="box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), 0 24px 80px rgba(0,0,0,0.34), 0 0 0 1px color-mix(in srgb, ' + metric.accent + ' 16%, transparent);">' +
|
||||
'<div class="metric-label">' + metric.label + '</div>' +
|
||||
'<div class="metric-value">' + metric.value + '</div>' +
|
||||
'<div class="metric-detail">' + metric.detail + '</div>' +
|
||||
metricSpark(metric.spark) +
|
||||
'</article>'
|
||||
))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function renderStories(stories) {
|
||||
storyList.innerHTML = stories
|
||||
.map((story, index) => (
|
||||
'<article class="story-item">' +
|
||||
'<div class="story-rank">0' + (index + 1) + '</div>' +
|
||||
'<a class="story-title" href="' + story.url + '" target="_blank" rel="noreferrer">' + story.title + '</a>' +
|
||||
'<div class="story-meta">' +
|
||||
'<span class="story-pill">' + formatter.format(story.score) + ' pts</span>' +
|
||||
'<span class="story-pill">' + story.descendants + ' comments</span>' +
|
||||
'<span class="story-pill">by ' + story.by + '</span>' +
|
||||
'</div>' +
|
||||
'</article>'
|
||||
))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function renderRepos(repos) {
|
||||
repoList.innerHTML = repos
|
||||
.map((repo) => (
|
||||
'<article class="repo-item">' +
|
||||
'<a class="repo-name" href="' + repo.url + '" target="_blank" rel="noreferrer">' + repo.label + '</a>' +
|
||||
'<p class="repo-description">' + repo.description + '</p>' +
|
||||
'<div class="repo-meta">' +
|
||||
'<span class="repo-pill">' + formatter.format(repo.stars) + ' stars</span>' +
|
||||
'<span class="repo-pill">' + formatter.format(repo.forks) + ' forks</span>' +
|
||||
'<span class="repo-pill">' + repo.issues + ' open issues</span>' +
|
||||
'</div>' +
|
||||
'</article>'
|
||||
))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function renderErrorState(message) {
|
||||
metricGrid.innerHTML = '<div class="empty-state">' + message + '</div>';
|
||||
storyList.innerHTML = '<div class="empty-state">No stories available.</div>';
|
||||
repoList.innerHTML = '<div class="empty-state">No repositories available.</div>';
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
heroStatus.textContent = 'Refreshing live signals...';
|
||||
|
||||
try {
|
||||
const [repos, stories] = await Promise.all([loadRepos(), loadStories()]);
|
||||
|
||||
if (!repos.length || !stories.length) {
|
||||
renderErrorState('The sample site loaded, but the data sources returned no content.');
|
||||
heroStatus.textContent = 'Loaded with empty data.';
|
||||
return;
|
||||
}
|
||||
|
||||
renderMetrics(repos, stories);
|
||||
renderStories(stories);
|
||||
renderRepos(repos);
|
||||
|
||||
heroStatus.textContent = 'Updated ' + new Date().toLocaleTimeString([], {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}) + ' · embedded from sites/example-dashboard';
|
||||
} catch (error) {
|
||||
renderErrorState('This site is running, but the live fetch failed. The local scaffold is still valid.');
|
||||
heroStatus.textContent = error instanceof Error ? error.message : 'Refresh failed';
|
||||
}
|
||||
}
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 120000);
|
||||
`,
|
||||
}
|
||||
|
|
@ -1,5 +1,18 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
const IFRAME_LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
|
||||
|
||||
export function isAllowedIframeUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol === 'https:') return true;
|
||||
if (parsed.protocol !== 'http:') return false;
|
||||
return IFRAME_LOCAL_HOSTS.has(parsed.hostname.toLowerCase());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const ImageBlockSchema = z.object({
|
||||
src: z.string(),
|
||||
alt: z.string().optional(),
|
||||
|
|
@ -16,6 +29,18 @@ export const EmbedBlockSchema = z.object({
|
|||
|
||||
export type EmbedBlock = z.infer<typeof EmbedBlockSchema>;
|
||||
|
||||
export const IframeBlockSchema = z.object({
|
||||
url: z.string().url().refine(isAllowedIframeUrl, {
|
||||
message: 'Iframe URLs must use https:// or local http://localhost / 127.0.0.1.',
|
||||
}),
|
||||
title: z.string().optional(),
|
||||
caption: z.string().optional(),
|
||||
height: z.number().int().min(240).max(1600).optional(),
|
||||
allow: z.string().optional(),
|
||||
});
|
||||
|
||||
export type IframeBlock = z.infer<typeof IframeBlockSchema>;
|
||||
|
||||
export const ChartBlockSchema = z.object({
|
||||
chart: z.enum(['line', 'bar', 'pie']),
|
||||
title: z.string().optional(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue