@pagepocket/content-reader 0.13.0
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/browser/content-reader.css +1 -0
- package/dist/browser/content-reader.iife.js +60 -0
- package/dist/browser-entry.d.ts +1 -0
- package/dist/browser-entry.js +24 -0
- package/dist/code-block.d.ts +7 -0
- package/dist/code-block.js +21 -0
- package/dist/content-reader.d.ts +2 -0
- package/dist/content-reader.js +23 -0
- package/dist/hooks/use-active-heading.d.ts +6 -0
- package/dist/hooks/use-active-heading.js +29 -0
- package/dist/hooks/use-headings.d.ts +6 -0
- package/dist/hooks/use-headings.js +31 -0
- package/dist/html-renderer.d.ts +11 -0
- package/dist/html-renderer.js +46 -0
- package/dist/image-zoom-wrapper.d.ts +6 -0
- package/dist/image-zoom-wrapper.js +5 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/table-of-contents.d.ts +7 -0
- package/dist/table-of-contents.js +17 -0
- package/dist/theme-provider.d.ts +10 -0
- package/dist/theme-provider.js +50 -0
- package/dist/types.d.ts +23 -0
- package/dist/types.js +1 -0
- package/package.json +48 -0
- package/src/styles.css +217 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import "./styles.css";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import "./styles.css";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { ContentReader } from "./content-reader.js";
|
|
5
|
+
/**
|
|
6
|
+
* Mount the ContentReader into a target DOM element.
|
|
7
|
+
*
|
|
8
|
+
* Called from the shell HTML generated by `pp view` for main-content snapshots.
|
|
9
|
+
* Reads the raw HTML from a hidden `<template>` element and renders
|
|
10
|
+
* the full interactive reader (ToC, dark mode, code highlighting, image zoom).
|
|
11
|
+
*
|
|
12
|
+
* Usage (from generated shell HTML):
|
|
13
|
+
* ```js
|
|
14
|
+
* window.__PP_CONTENT_READER__.mount(
|
|
15
|
+
* document.getElementById('root'),
|
|
16
|
+
* document.getElementById('pp-content').innerHTML
|
|
17
|
+
* );
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
const mount = (container, html) => {
|
|
21
|
+
const root = createRoot(container);
|
|
22
|
+
root.render(_jsx(ContentReader, { html: html }));
|
|
23
|
+
};
|
|
24
|
+
window.__PP_CONTENT_READER__ = { mount };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import hljs from "highlight.js";
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
export const CodeBlock = ({ children, className }) => {
|
|
5
|
+
const codeRef = useRef(null);
|
|
6
|
+
const [copied, setCopied] = useState(false);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
if (codeRef.current) {
|
|
9
|
+
hljs.highlightElement(codeRef.current);
|
|
10
|
+
}
|
|
11
|
+
}, [children, className]);
|
|
12
|
+
const handleCopy = () => {
|
|
13
|
+
if (!codeRef.current)
|
|
14
|
+
return;
|
|
15
|
+
navigator.clipboard.writeText(codeRef.current.innerText).then(() => {
|
|
16
|
+
setCopied(true);
|
|
17
|
+
setTimeout(() => setCopied(false), 2000);
|
|
18
|
+
});
|
|
19
|
+
};
|
|
20
|
+
return (_jsxs("div", { className: "relative group my-6 rounded-md overflow-hidden border border-gray-200 dark:border-gray-800", children: [_jsx("div", { className: "absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity z-10", children: _jsx("button", { onClick: handleCopy, className: "bg-white dark:bg-gray-700 text-xs px-2 py-1 rounded shadow hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors", type: "button", children: copied ? "Copied!" : "Copy" }) }), _jsx("pre", { className: "!m-0 !p-4 bg-[var(--color-notion-code-bg-light)] dark:bg-[var(--color-notion-code-bg-dark)] overflow-auto text-sm", children: _jsx("code", { ref: codeRef, className: className || "language-plaintext", children: children }) })] }));
|
|
21
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useRef, useState } from "react";
|
|
3
|
+
import { useActiveHeading } from "./hooks/use-active-heading.js";
|
|
4
|
+
import { useHeadings } from "./hooks/use-headings.js";
|
|
5
|
+
import { HtmlRenderer } from "./html-renderer.js";
|
|
6
|
+
import { TableOfContents } from "./table-of-contents.js";
|
|
7
|
+
import { ThemeProvider, useTheme } from "./theme-provider.js";
|
|
8
|
+
const Icons = {
|
|
9
|
+
Sun: () => (_jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", className: "w-5 h-5", children: [_jsx("circle", { cx: "12", cy: "12", r: "5" }), _jsx("path", { d: "M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" })] })),
|
|
10
|
+
Moon: () => (_jsx("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", className: "w-5 h-5", children: _jsx("path", { d: "M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" }) })),
|
|
11
|
+
Menu: () => (_jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", className: "w-5 h-5", children: [_jsx("line", { x1: "3", y1: "12", x2: "21", y2: "12" }), _jsx("line", { x1: "3", y1: "6", x2: "21", y2: "6" }), _jsx("line", { x1: "3", y1: "18", x2: "21", y2: "18" })] }))
|
|
12
|
+
};
|
|
13
|
+
const ContentReaderInner = ({ html, showToc = true, className }) => {
|
|
14
|
+
const contentRef = useRef(null);
|
|
15
|
+
const headings = useHeadings(contentRef);
|
|
16
|
+
const activeId = useActiveHeading(headings);
|
|
17
|
+
const { theme, toggleTheme } = useTheme();
|
|
18
|
+
const [isMobileTocOpen, setMobileTocOpen] = useState(false);
|
|
19
|
+
return (_jsxs("div", { className: `min-h-screen bg-[var(--bg-primary)] text-[var(--text-primary)] transition-colors duration-200 ${className || ""}`, children: [_jsxs("header", { className: "sticky top-0 z-40 flex items-center justify-between px-4 py-3 bg-[var(--bg-primary)]/90 backdrop-blur border-b border-[var(--border-primary)] lg:hidden", children: [_jsx("span", { className: "font-semibold text-sm", children: "Reading" }), _jsxs("div", { className: "flex gap-2", children: [_jsx("button", { onClick: toggleTheme, className: "p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors", "aria-label": "Toggle theme", children: theme === "light" ? _jsx(Icons.Moon, {}) : _jsx(Icons.Sun, {}) }), showToc && headings.length > 0 && (_jsx("button", { onClick: () => setMobileTocOpen(!isMobileTocOpen), className: "p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors", "aria-label": "Toggle table of contents", children: _jsx(Icons.Menu, {}) }))] })] }), _jsxs("div", { className: "max-w-[1200px] mx-auto flex items-start relative", children: [showToc && headings.length > 0 && (_jsxs("aside", { className: "hidden lg:block w-[280px] sticky top-0 h-screen overflow-hidden border-r border-[var(--border-primary)] flex flex-col", children: [_jsxs("div", { className: "flex justify-between items-center p-4 border-b border-[var(--border-primary)] bg-[var(--bg-primary)]", children: [_jsx("span", { className: "text-xs font-semibold text-gray-500 uppercase tracking-wider", children: "Contents" }), _jsx("button", { onClick: toggleTheme, className: "p-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 transition-colors", "aria-label": "Toggle theme", children: theme === "light" ? _jsx(Icons.Moon, {}) : _jsx(Icons.Sun, {}) })] }), _jsx(TableOfContents, { headings: headings, activeId: activeId })] })), showToc && headings.length > 0 && (_jsxs(_Fragment, { children: [_jsx("div", { className: `fixed inset-0 bg-black/50 z-40 lg:hidden transition-opacity duration-300 ${isMobileTocOpen ? "opacity-100" : "opacity-0 pointer-events-none"}`, onClick: () => setMobileTocOpen(false) }), _jsxs("aside", { className: `fixed right-0 top-0 bottom-0 w-[280px] bg-[var(--bg-primary)] z-50 shadow-xl transition-transform duration-300 transform lg:hidden flex flex-col ${isMobileTocOpen ? "translate-x-0" : "translate-x-full"}`, children: [_jsxs("div", { className: "flex items-center justify-between p-4 border-b border-[var(--border-primary)]", children: [_jsx("span", { className: "font-semibold", children: "Contents" }), _jsx("button", { onClick: () => setMobileTocOpen(false), className: "p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 rounded", children: "\u2715" })] }), _jsx(TableOfContents, { headings: headings, activeId: activeId })] })] })), _jsx("main", { className: `flex-1 min-w-0 px-4 py-8 lg:px-12 lg:py-16 ${!showToc ? "lg:mx-auto max-w-[800px]" : ""}`, children: _jsx("div", { ref: contentRef, children: _jsx(HtmlRenderer, { html: html }) }) })] })] }));
|
|
20
|
+
};
|
|
21
|
+
export const ContentReader = (props) => {
|
|
22
|
+
return (_jsx(ThemeProvider, { initialTheme: props.theme, onThemeChange: props.onThemeChange, children: _jsx(ContentReaderInner, { html: props.html, showToc: props.showToc, className: props.className }) }));
|
|
23
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Tracks which heading is currently active in the viewport.
|
|
4
|
+
* Uses IntersectionObserver to determine visibility.
|
|
5
|
+
*/
|
|
6
|
+
export function useActiveHeading(headings) {
|
|
7
|
+
const [activeId, setActiveId] = useState("");
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
if (headings.length === 0)
|
|
10
|
+
return;
|
|
11
|
+
const observer = new IntersectionObserver((entries) => {
|
|
12
|
+
entries.forEach((entry) => {
|
|
13
|
+
if (entry.isIntersecting) {
|
|
14
|
+
setActiveId(entry.target.id);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
}, {
|
|
18
|
+
rootMargin: "-20% 0% -35% 0%",
|
|
19
|
+
threshold: 0.5
|
|
20
|
+
});
|
|
21
|
+
headings.forEach((heading) => {
|
|
22
|
+
const element = document.getElementById(heading.id);
|
|
23
|
+
if (element)
|
|
24
|
+
observer.observe(element);
|
|
25
|
+
});
|
|
26
|
+
return () => observer.disconnect();
|
|
27
|
+
}, [headings]);
|
|
28
|
+
return activeId;
|
|
29
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Extracts h1-h6 headings from the rendered content.
|
|
4
|
+
* Should be called after content is rendered.
|
|
5
|
+
*/
|
|
6
|
+
export function useHeadings(contentRef) {
|
|
7
|
+
const [headings, setHeadings] = useState([]);
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
if (!contentRef.current)
|
|
10
|
+
return;
|
|
11
|
+
const elements = contentRef.current.querySelectorAll("h1, h2, h3, h4, h5, h6");
|
|
12
|
+
const newHeadings = [];
|
|
13
|
+
elements.forEach((el) => {
|
|
14
|
+
// Ensure element has an ID for linking
|
|
15
|
+
if (!el.id) {
|
|
16
|
+
el.id =
|
|
17
|
+
el.textContent
|
|
18
|
+
?.toLowerCase()
|
|
19
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
20
|
+
.replace(/(^-|-$)/g, "") || `heading-${Math.random().toString(36).substr(2, 9)}`;
|
|
21
|
+
}
|
|
22
|
+
newHeadings.push({
|
|
23
|
+
id: el.id,
|
|
24
|
+
text: el.textContent || "",
|
|
25
|
+
level: parseInt(el.tagName.substring(1), 10)
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
setHeadings(newHeadings);
|
|
29
|
+
}, [contentRef]); // Re-run if ref changes (though ref object stable, content inside might change if we added deps)
|
|
30
|
+
return headings;
|
|
31
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
interface HtmlRendererProps {
|
|
2
|
+
html: string;
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* Renders an HTML string into React elements with custom replacements:
|
|
6
|
+
* - `<pre><code>` blocks are replaced with `CodeBlock` (syntax highlighting + copy)
|
|
7
|
+
* - `<img>` elements are replaced with `ImageZoomWrapper` (click-to-zoom)
|
|
8
|
+
* - Heading elements (h1-h6) get auto-generated IDs for ToC anchor links
|
|
9
|
+
*/
|
|
10
|
+
export declare const HtmlRenderer: ({ html }: HtmlRendererProps) => import("react/jsx-runtime").JSX.Element;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import parse, { Element, domToReact } from "html-react-parser";
|
|
3
|
+
import { CodeBlock } from "./code-block.js";
|
|
4
|
+
import { ImageZoomWrapper } from "./image-zoom-wrapper.js";
|
|
5
|
+
const extractTextContent = (children) => children
|
|
6
|
+
.filter((child) => child.type === "text")
|
|
7
|
+
.map((textNode) => textNode.data)
|
|
8
|
+
.join("");
|
|
9
|
+
const slugify = (text) => text
|
|
10
|
+
.toLowerCase()
|
|
11
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
12
|
+
.replace(/(^-|-$)/g, "");
|
|
13
|
+
/**
|
|
14
|
+
* Renders an HTML string into React elements with custom replacements:
|
|
15
|
+
* - `<pre><code>` blocks are replaced with `CodeBlock` (syntax highlighting + copy)
|
|
16
|
+
* - `<img>` elements are replaced with `ImageZoomWrapper` (click-to-zoom)
|
|
17
|
+
* - Heading elements (h1-h6) get auto-generated IDs for ToC anchor links
|
|
18
|
+
*/
|
|
19
|
+
export const HtmlRenderer = ({ html }) => {
|
|
20
|
+
const options = {
|
|
21
|
+
replace: (domNode) => {
|
|
22
|
+
if (!(domNode instanceof Element)) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (domNode.name === "pre" &&
|
|
26
|
+
domNode.children.length === 1 &&
|
|
27
|
+
domNode.children[0] instanceof Element &&
|
|
28
|
+
domNode.children[0].name === "code") {
|
|
29
|
+
const codeElement = domNode.children[0];
|
|
30
|
+
const codeClassName = codeElement.attribs.class || "";
|
|
31
|
+
return (_jsx(CodeBlock, { className: codeClassName, children: domToReact(codeElement.children) }));
|
|
32
|
+
}
|
|
33
|
+
if (domNode.name === "img") {
|
|
34
|
+
return (_jsx(ImageZoomWrapper, { src: domNode.attribs.src, alt: domNode.attribs.alt, className: domNode.attribs.class }));
|
|
35
|
+
}
|
|
36
|
+
if (/^h[1-6]$/.test(domNode.name) && !domNode.attribs.id) {
|
|
37
|
+
const headingText = extractTextContent(domNode.children);
|
|
38
|
+
const headingId = slugify(headingText);
|
|
39
|
+
if (headingId) {
|
|
40
|
+
domNode.attribs.id = headingId;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
return _jsx("div", { className: "prose-notion", children: parse(html, options) });
|
|
46
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import Zoom from "react-medium-image-zoom";
|
|
3
|
+
export const ImageZoomWrapper = (props) => {
|
|
4
|
+
return (_jsx(Zoom, { children: _jsx("img", { ...props, alt: props.alt || "Content image", className: `max-w-full rounded-md shadow-sm mx-auto my-6 cursor-zoom-in ${props.className || ""}`, loading: "lazy" }) }));
|
|
5
|
+
};
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Heading } from "./types.js";
|
|
2
|
+
interface TableOfContentsProps {
|
|
3
|
+
headings: Heading[];
|
|
4
|
+
activeId: string;
|
|
5
|
+
}
|
|
6
|
+
export declare const TableOfContents: ({ headings, activeId }: TableOfContentsProps) => import("react/jsx-runtime").JSX.Element | null;
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
export const TableOfContents = ({ headings, activeId }) => {
|
|
3
|
+
if (headings.length === 0)
|
|
4
|
+
return null;
|
|
5
|
+
const handleClick = (e, id) => {
|
|
6
|
+
e.preventDefault();
|
|
7
|
+
const element = document.getElementById(id);
|
|
8
|
+
if (element) {
|
|
9
|
+
element.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
10
|
+
// Update URL hash without jumping
|
|
11
|
+
history.pushState(null, "", `#${id}`);
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
return (_jsxs("nav", { className: "text-sm overflow-y-auto max-h-[calc(100vh-4rem)] p-4 pr-2", children: [_jsx("h4", { className: "font-semibold mb-3 text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider", children: "Table of Contents" }), _jsx("ul", { className: "space-y-1", children: headings.map((heading) => (_jsx("li", { style: { paddingLeft: `${(heading.level - 1) * 0.75}rem` }, className: `transition-colors duration-200 border-l-2 ${activeId === heading.id
|
|
15
|
+
? "border-blue-500 text-blue-600 dark:text-blue-400 font-medium"
|
|
16
|
+
: "border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"}`, children: _jsx("a", { href: `#${heading.id}`, onClick: (e) => handleClick(e, heading.id), className: "block py-1 pl-2 truncate", title: heading.text, children: heading.text }) }, heading.id))) })] }));
|
|
17
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Theme, ThemeContextType } from "./types.js";
|
|
3
|
+
interface ThemeProviderProps {
|
|
4
|
+
initialTheme?: Theme;
|
|
5
|
+
onThemeChange?: (theme: Theme) => void;
|
|
6
|
+
children: React.ReactNode;
|
|
7
|
+
}
|
|
8
|
+
export declare const ThemeProvider: ({ initialTheme, onThemeChange, children }: ThemeProviderProps) => import("react/jsx-runtime").JSX.Element;
|
|
9
|
+
export declare const useTheme: () => ThemeContextType;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
|
3
|
+
const ThemeContext = createContext(undefined);
|
|
4
|
+
const applyDarkClass = (theme) => {
|
|
5
|
+
document.documentElement.classList.toggle("dark", theme === "dark");
|
|
6
|
+
};
|
|
7
|
+
export const ThemeProvider = ({ initialTheme = "light", onThemeChange, children }) => {
|
|
8
|
+
const [theme, setThemeState] = useState(initialTheme);
|
|
9
|
+
const setTheme = useCallback((newTheme) => {
|
|
10
|
+
const update = () => {
|
|
11
|
+
applyDarkClass(newTheme);
|
|
12
|
+
setThemeState(newTheme);
|
|
13
|
+
onThemeChange?.(newTheme);
|
|
14
|
+
};
|
|
15
|
+
if (document.startViewTransition) {
|
|
16
|
+
document.startViewTransition(update);
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
update();
|
|
20
|
+
}
|
|
21
|
+
}, [onThemeChange]);
|
|
22
|
+
const toggleTheme = useCallback(() => {
|
|
23
|
+
setThemeState((prev) => {
|
|
24
|
+
const next = prev === "light" ? "dark" : "light";
|
|
25
|
+
const update = () => {
|
|
26
|
+
applyDarkClass(next);
|
|
27
|
+
onThemeChange?.(next);
|
|
28
|
+
};
|
|
29
|
+
if (document.startViewTransition) {
|
|
30
|
+
document.startViewTransition(update);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
update();
|
|
34
|
+
}
|
|
35
|
+
return next;
|
|
36
|
+
});
|
|
37
|
+
}, [onThemeChange]);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
applyDarkClass(initialTheme);
|
|
40
|
+
setThemeState(initialTheme);
|
|
41
|
+
}, [initialTheme]);
|
|
42
|
+
return (_jsx(ThemeContext.Provider, { value: { theme, toggleTheme, setTheme }, children: children }));
|
|
43
|
+
};
|
|
44
|
+
export const useTheme = () => {
|
|
45
|
+
const context = useContext(ThemeContext);
|
|
46
|
+
if (!context) {
|
|
47
|
+
throw new Error("useTheme must be used within a ThemeProvider");
|
|
48
|
+
}
|
|
49
|
+
return context;
|
|
50
|
+
};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type Theme = "light" | "dark";
|
|
2
|
+
export interface Heading {
|
|
3
|
+
id: string;
|
|
4
|
+
text: string;
|
|
5
|
+
level: number;
|
|
6
|
+
}
|
|
7
|
+
export interface ContentReaderProps {
|
|
8
|
+
/** The raw HTML content string to render */
|
|
9
|
+
html: string;
|
|
10
|
+
/** Initial theme preference. Defaults to 'light' */
|
|
11
|
+
theme?: Theme;
|
|
12
|
+
/** Whether to show the Table of Contents sidebar. Defaults to true */
|
|
13
|
+
showToc?: boolean;
|
|
14
|
+
/** Optional class name for the container */
|
|
15
|
+
className?: string;
|
|
16
|
+
/** Callback when theme changes */
|
|
17
|
+
onThemeChange?: (theme: Theme) => void;
|
|
18
|
+
}
|
|
19
|
+
export interface ThemeContextType {
|
|
20
|
+
theme: Theme;
|
|
21
|
+
toggleTheme: () => void;
|
|
22
|
+
setTheme: (theme: Theme) => void;
|
|
23
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pagepocket/content-reader",
|
|
3
|
+
"version": "0.13.0",
|
|
4
|
+
"description": "Notion-style HTML content reader with syntax highlighting, dark mode, ToC, and image lightbox.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"src/styles.css"
|
|
11
|
+
],
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./styles.css": "./src/styles.css",
|
|
18
|
+
"./browser/js": "./dist/browser/content-reader.iife.js",
|
|
19
|
+
"./browser/css": "./dist/browser/content-reader.css"
|
|
20
|
+
},
|
|
21
|
+
"license": "ISC",
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"highlight.js": "^11.11.1",
|
|
24
|
+
"html-react-parser": "^5.2.4",
|
|
25
|
+
"react-medium-image-zoom": "^5.2.13"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@tailwindcss/vite": "^4.1.8",
|
|
29
|
+
"@types/node": "^20.17.12",
|
|
30
|
+
"@types/react": "^19.1.6",
|
|
31
|
+
"@types/react-dom": "^19.1.6",
|
|
32
|
+
"@vitejs/plugin-react": "^4.5.2",
|
|
33
|
+
"react": "^19.1.0",
|
|
34
|
+
"react-dom": "^19.1.0",
|
|
35
|
+
"tailwindcss": "^4.2.0",
|
|
36
|
+
"typescript": "^5.7.2",
|
|
37
|
+
"vite": "^6.3.5"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"react": ">=18.0.0",
|
|
41
|
+
"react-dom": ">=18.0.0"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsc -p tsconfig.json && pnpm build:bundle",
|
|
45
|
+
"build:bundle": "vite build",
|
|
46
|
+
"test": "echo 'no tests yet'"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/styles.css
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
@import "highlight.js/styles/github-dark.css";
|
|
3
|
+
@import "react-medium-image-zoom/dist/styles.css";
|
|
4
|
+
|
|
5
|
+
::view-transition-group(root) {
|
|
6
|
+
animation-duration: 1s;
|
|
7
|
+
}
|
|
8
|
+
::view-transition-new(root),
|
|
9
|
+
::view-transition-old(root) {
|
|
10
|
+
mix-blend-mode: normal;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
::view-transition-new(root) {
|
|
14
|
+
animation-name: reveal-light;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
::view-transition-old(root),
|
|
18
|
+
.dark::view-transition-old(root) {
|
|
19
|
+
animation: none;
|
|
20
|
+
}
|
|
21
|
+
.dark::view-transition-new(root) {
|
|
22
|
+
animation-name: reveal-dark;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
li {
|
|
26
|
+
margin-bottom: 5px;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@layer base {
|
|
30
|
+
img {
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@keyframes reveal-dark {
|
|
35
|
+
from {
|
|
36
|
+
clip-path: polygon(-30% 0, -30% 0, -15% 100%, -10% 115%);
|
|
37
|
+
}
|
|
38
|
+
to {
|
|
39
|
+
clip-path: polygon(-30% 0, 130% 0, 115% 100%, -10% 115%);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@keyframes reveal-light {
|
|
44
|
+
from {
|
|
45
|
+
clip-path: polygon(130% 0, 130% 0, 115% 100%, 110% 115%);
|
|
46
|
+
}
|
|
47
|
+
to {
|
|
48
|
+
clip-path: polygon(130% 0, -30% 0, -15% 100%, 110% 115%);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@custom-variant dark (&:is(.dark *));
|
|
53
|
+
|
|
54
|
+
@theme {
|
|
55
|
+
/* Colors */
|
|
56
|
+
--color-notion-bg-light: #ffffff;
|
|
57
|
+
--color-notion-text-light: #37352f;
|
|
58
|
+
--color-notion-bg-dark: #191919;
|
|
59
|
+
--color-notion-text-dark: #d4d4d4;
|
|
60
|
+
--color-notion-gray-light: #ebecef;
|
|
61
|
+
--color-notion-gray-dark: #2f2f2f;
|
|
62
|
+
--color-notion-border-light: #e9e9e8;
|
|
63
|
+
--color-notion-border-dark: #373737;
|
|
64
|
+
--color-notion-code-bg-light: #f7f6f3;
|
|
65
|
+
--color-notion-code-bg-dark: #1e1e1e;
|
|
66
|
+
--color-notion-quote-border-light: #37352f;
|
|
67
|
+
--color-notion-quote-border-dark: #d4d4d4;
|
|
68
|
+
|
|
69
|
+
/* Typography */
|
|
70
|
+
--font-sans:
|
|
71
|
+
ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
72
|
+
--font-mono: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* Base styles for the reader */
|
|
76
|
+
.prose-notion {
|
|
77
|
+
max-width: 720px;
|
|
78
|
+
margin-left: auto;
|
|
79
|
+
margin-right: auto;
|
|
80
|
+
font-family: var(--font-sans);
|
|
81
|
+
font-size: 16px;
|
|
82
|
+
line-height: 1.7;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* Light Mode Defaults */
|
|
86
|
+
:root {
|
|
87
|
+
--bg-primary: var(--color-notion-bg-light);
|
|
88
|
+
--text-primary: var(--color-notion-text-light);
|
|
89
|
+
--border-primary: var(--color-notion-border-light);
|
|
90
|
+
--code-bg: var(--color-notion-code-bg-light);
|
|
91
|
+
--quote-border: var(--color-notion-quote-border-light);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* Dark Mode Overrides */
|
|
95
|
+
.dark {
|
|
96
|
+
--bg-primary: var(--color-notion-bg-dark);
|
|
97
|
+
--text-primary: var(--color-notion-text-dark);
|
|
98
|
+
--border-primary: var(--color-notion-border-dark);
|
|
99
|
+
--code-bg: var(--color-notion-code-bg-dark);
|
|
100
|
+
--quote-border: var(--color-notion-quote-border-dark);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.prose-notion {
|
|
104
|
+
color: var(--text-primary);
|
|
105
|
+
background-color: var(--bg-primary);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* Typography elements */
|
|
109
|
+
.prose-notion h1 {
|
|
110
|
+
font-size: 2em;
|
|
111
|
+
font-weight: 700;
|
|
112
|
+
margin-top: 2em;
|
|
113
|
+
margin-bottom: 0.5em;
|
|
114
|
+
line-height: 1.3;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.prose-notion h2 {
|
|
118
|
+
font-size: 1.5em;
|
|
119
|
+
font-weight: 600;
|
|
120
|
+
margin-top: 1.4em;
|
|
121
|
+
margin-bottom: 0.4em;
|
|
122
|
+
line-height: 1.3;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.prose-notion h3 {
|
|
126
|
+
font-size: 1.25em;
|
|
127
|
+
font-weight: 600;
|
|
128
|
+
margin-top: 1.2em;
|
|
129
|
+
margin-bottom: 0.3em;
|
|
130
|
+
line-height: 1.3;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.prose-notion p {
|
|
134
|
+
margin-top: 0.5em;
|
|
135
|
+
margin-bottom: 0.5em;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.prose-notion blockquote {
|
|
139
|
+
border-left: 3px solid var(--quote-border);
|
|
140
|
+
padding-left: 1rem;
|
|
141
|
+
margin-top: 1em;
|
|
142
|
+
margin-bottom: 1em;
|
|
143
|
+
font-style: italic;
|
|
144
|
+
opacity: 0.8;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.prose-notion ul,
|
|
148
|
+
.prose-notion ol {
|
|
149
|
+
padding-left: 1.5em;
|
|
150
|
+
margin-top: 0.5em;
|
|
151
|
+
margin-bottom: 0.5em;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.prose-notion li {
|
|
155
|
+
margin-bottom: 0.25em;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.prose-notion hr {
|
|
159
|
+
margin: 2em 0;
|
|
160
|
+
border: 0;
|
|
161
|
+
border-top: 1px solid var(--border-primary);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.prose-notion a {
|
|
165
|
+
text-decoration: underline;
|
|
166
|
+
text-decoration-color: rgba(120, 119, 116, 0.4);
|
|
167
|
+
text-underline-offset: 4px;
|
|
168
|
+
color: inherit;
|
|
169
|
+
transition: text-decoration-color 0.1s;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.prose-notion a:hover {
|
|
173
|
+
text-decoration-color: var(--text-primary);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.prose-notion img {
|
|
177
|
+
max-width: 100%;
|
|
178
|
+
border-radius: 4px;
|
|
179
|
+
display: block;
|
|
180
|
+
margin: 1.5em auto;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* Tables */
|
|
184
|
+
.prose-notion table {
|
|
185
|
+
width: 100%;
|
|
186
|
+
border-collapse: collapse;
|
|
187
|
+
margin: 1.5em 0;
|
|
188
|
+
font-size: 0.9em;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.prose-notion th,
|
|
192
|
+
.prose-notion td {
|
|
193
|
+
border: 1px solid var(--border-primary);
|
|
194
|
+
padding: 8px 12px;
|
|
195
|
+
text-align: left;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.prose-notion th {
|
|
199
|
+
background-color: rgba(120, 119, 116, 0.05);
|
|
200
|
+
font-weight: 600;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/* Inline code */
|
|
204
|
+
.prose-notion :not(pre) > code {
|
|
205
|
+
background: var(--code-bg);
|
|
206
|
+
padding: 2px 6px;
|
|
207
|
+
border-radius: 3px;
|
|
208
|
+
font-family: var(--font-mono);
|
|
209
|
+
font-size: 0.9em;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/* Code blocks (handled by CodeBlock component, reset base styles) */
|
|
213
|
+
.prose-notion pre {
|
|
214
|
+
background: transparent;
|
|
215
|
+
padding: 0;
|
|
216
|
+
margin: 0;
|
|
217
|
+
}
|