@sapphirelabs/blog-cms-next 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sapphire Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,52 @@
1
- # Temporary Holding Version
1
+ # @sapphirelabs/blog-cms-next
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [Sapphire Blog CMS](https://github.com/sapphire-labs-dev/sapphire-blog-cms) for Next.js (App Router):
4
+ a CMS panel at `/admin`, helpers to load your Markdown posts, and a component that renders them safely.
5
+
6
+ ```bash
7
+ npm install @sapphirelabs/blog-cms-next
8
+ ```
9
+
10
+ ```ts
11
+ // app/admin/route.ts
12
+ import { createAdminRoute } from '@sapphirelabs/blog-cms-next/admin';
13
+
14
+ export const { GET } = createAdminRoute({
15
+ scriptUrl: 'https://unpkg.com/@sapphirelabs/blog-cms@0.1.0/dist/sapphire-cms.js',
16
+ });
17
+ export const dynamic = 'force-static';
18
+ ```
19
+
20
+ ```tsx
21
+ // app/blog/[slug]/page.tsx
22
+ import { notFound } from 'next/navigation';
23
+ import { getPost, getSlugs, SapphireContent } from '@sapphirelabs/blog-cms-next';
24
+
25
+ export async function generateStaticParams() {
26
+ return (await getSlugs('content/blog')).map((slug) => ({ slug }));
27
+ }
28
+
29
+ export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
30
+ const post = await getPost('content/blog', (await params).slug);
31
+ if (!post) notFound();
32
+
33
+ return <SapphireContent source={post.body} />;
34
+ }
35
+ ```
36
+
37
+ ```tsx
38
+ // app/layout.tsx
39
+ import '@sapphirelabs/blog-cms-next/prose.css';
40
+ ```
41
+
42
+ - `getPosts(folder)`: every post, newest first; `draft: true` posts are hidden in production.
43
+ - `getPost(folder, slug)`: one post, or `null`.
44
+ - `<SapphireContent source>`: renders Markdown plus the HTML the editor writes (color, underline,
45
+ alignment, tables, YouTube) and strips anything that could run code. `allowUnsafeHtml` turns that off.
46
+
47
+ Full setup and the `config.yml` reference:
48
+ [getting started](https://github.com/sapphire-labs-dev/sapphire-blog-cms/blob/main/docs/getting-started.md).
49
+
50
+ ## License
51
+
52
+ MIT
@@ -0,0 +1,18 @@
1
+ export interface SapphireContentProps {
2
+ /** The post text (the `body` from `getPost`). */
3
+ source: string;
4
+ /** Class of the wrapping element. Default: `sapphire-prose`, the theme in `@sapphirelabs/blog-cms-next/prose.css`. */
5
+ className?: string;
6
+ as?: 'article' | 'div' | 'section';
7
+ /**
8
+ * Skips HTML sanitizing. Only turn this on if every editor is fully trusted and your posts need
9
+ * HTML beyond what the editor produces (scripts, forms, custom embeds).
10
+ */
11
+ allowUnsafeHtml?: boolean;
12
+ }
13
+ /**
14
+ * Renders text saved by Sapphire Blog CMS. Besides Markdown, it accepts the HTML the editor writes
15
+ * for color, underline, highlight, alignment, tables and YouTube videos. Anything else that could run
16
+ * code (scripts, event handlers, other iframes) is removed, unless `allowUnsafeHtml` is set.
17
+ */
18
+ export declare function SapphireContent({ source, className, as: Tag, allowUnsafeHtml }: SapphireContentProps): import("react").JSX.Element;
@@ -0,0 +1,64 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import Markdown from 'react-markdown';
3
+ import rehypeRaw from 'rehype-raw';
4
+ import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
5
+ import remarkGfm from 'remark-gfm';
6
+ /** The HTML the editor writes, on top of the standard safe Markdown subset. */
7
+ const schema = {
8
+ ...defaultSchema,
9
+ tagNames: [...(defaultSchema.tagNames ?? []), 'u', 'mark', 'iframe', 'figure', 'figcaption'],
10
+ attributes: {
11
+ ...defaultSchema.attributes,
12
+ '*': [...(defaultSchema.attributes?.['*'] ?? []), 'style', 'className'],
13
+ mark: ['dataColor'],
14
+ div: ['dataYoutubeVideo'],
15
+ iframe: ['src', 'width', 'height', 'title', 'allowFullScreen', 'allow', 'loading'],
16
+ },
17
+ };
18
+ const ALLOWED_STYLES = new Set(['color', 'background-color', 'text-align']);
19
+ const YOUTUBE_EMBED = /^https:\/\/(www\.)?(youtube\.com|youtube-nocookie\.com)\/embed\//;
20
+ /** Keeps only the style properties the editor uses (no url(), no layout tricks). */
21
+ function cleanStyle(style) {
22
+ return style
23
+ .split(';')
24
+ .map((part) => part.split(':').map((s) => s.trim()))
25
+ .filter(([prop, value]) => prop && value && ALLOWED_STYLES.has(prop.toLowerCase()) && /^[#\w\s(),.%-]+$/.test(value) && !/url|expression/i.test(value))
26
+ .map(([prop, value]) => `${prop.toLowerCase()}: ${value}`)
27
+ .join('; ');
28
+ }
29
+ /** Drops iframes that are not YouTube embeds and trims inline styles. */
30
+ function rehypeSapphireEmbeds() {
31
+ const walk = (node) => {
32
+ if (!node.children) {
33
+ return;
34
+ }
35
+ node.children = node.children.filter((child) => {
36
+ if (child.type !== 'element') {
37
+ return true;
38
+ }
39
+ if (child.tagName === 'iframe' && !YOUTUBE_EMBED.test(String(child.properties?.src ?? ''))) {
40
+ return false;
41
+ }
42
+ if (typeof child.properties?.style === 'string') {
43
+ const style = cleanStyle(child.properties.style);
44
+ if (style) {
45
+ child.properties.style = style;
46
+ }
47
+ else {
48
+ delete child.properties.style;
49
+ }
50
+ }
51
+ walk(child);
52
+ return true;
53
+ });
54
+ };
55
+ return (tree) => walk(tree);
56
+ }
57
+ /**
58
+ * Renders text saved by Sapphire Blog CMS. Besides Markdown, it accepts the HTML the editor writes
59
+ * for color, underline, highlight, alignment, tables and YouTube videos. Anything else that could run
60
+ * code (scripts, event handlers, other iframes) is removed, unless `allowUnsafeHtml` is set.
61
+ */
62
+ export function SapphireContent({ source, className = 'sapphire-prose', as: Tag = 'article', allowUnsafeHtml = false }) {
63
+ return (_jsx(Tag, { className: className, children: _jsx(Markdown, { remarkPlugins: [remarkGfm], rehypePlugins: allowUnsafeHtml ? [rehypeRaw] : [rehypeRaw, [rehypeSanitize, schema], rehypeSapphireEmbeds], children: source }) }));
64
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * CMS panel page for the App Router. In `app/admin/route.ts`:
3
+ *
4
+ * import { createAdminRoute } from '@sapphirelabs/blog-cms-next/admin';
5
+ * export const { GET } = createAdminRoute();
6
+ * export const dynamic = 'force-static';
7
+ *
8
+ * Without `config`, the panel reads `public/admin/config.yml`.
9
+ */
10
+ export declare const DEFAULT_SCRIPT_URL = "https://unpkg.com/@sapphirelabs/blog-cms/dist/sapphire-cms.js";
11
+ export interface AdminOptions {
12
+ /** URL of `sapphire-cms.js`. Default: the latest version on unpkg. */
13
+ scriptUrl?: string;
14
+ /** Configuration instead of `config.yml`, in the same format. */
15
+ config?: Record<string, unknown>;
16
+ /** Browser tab title. */
17
+ title?: string;
18
+ }
19
+ export declare function adminHtml({ scriptUrl, config, title }?: AdminOptions): string;
20
+ export declare function createAdminRoute(options?: AdminOptions): {
21
+ GET: () => Response;
22
+ };
package/dist/admin.js ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * CMS panel page for the App Router. In `app/admin/route.ts`:
3
+ *
4
+ * import { createAdminRoute } from '@sapphirelabs/blog-cms-next/admin';
5
+ * export const { GET } = createAdminRoute();
6
+ * export const dynamic = 'force-static';
7
+ *
8
+ * Without `config`, the panel reads `public/admin/config.yml`.
9
+ */
10
+ export const DEFAULT_SCRIPT_URL = 'https://unpkg.com/@sapphirelabs/blog-cms/dist/sapphire-cms.js';
11
+ const escapeHtml = (value) => value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
12
+ export function adminHtml({ scriptUrl = DEFAULT_SCRIPT_URL, config, title } = {}) {
13
+ const pageTitle = title ?? (typeof config?.site_title === 'string' ? config.site_title : 'Content Manager');
14
+ const inline = config
15
+ ? `<script>window.SAPPHIRE_CMS_CONFIG = ${JSON.stringify(config).replace(/</g, '\\u003c')};</script>`
16
+ : '';
17
+ return `<!doctype html>
18
+ <html lang="en">
19
+ <head>
20
+ <meta charset="utf-8" />
21
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
22
+ <meta name="robots" content="noindex, nofollow" />
23
+ <title>${escapeHtml(pageTitle)}</title>
24
+ <style>body { margin: 0; }</style>
25
+ ${inline}
26
+ <script src="${escapeHtml(scriptUrl)}"></script>
27
+ </head>
28
+ <body></body>
29
+ </html>
30
+ `;
31
+ }
32
+ export function createAdminRoute(options = {}) {
33
+ const html = adminHtml(options);
34
+ return {
35
+ GET: () => new Response(html, {
36
+ headers: { 'Content-Type': 'text/html; charset=utf-8', 'X-Robots-Tag': 'noindex' },
37
+ }),
38
+ };
39
+ }
@@ -0,0 +1,29 @@
1
+ /** A collection file: the front matter fields in `data` and the text in `body`. */
2
+ export interface Post<T = Record<string, unknown>> {
3
+ slug: string;
4
+ data: T;
5
+ body: string;
6
+ }
7
+ export interface ReadOptions {
8
+ /**
9
+ * Include posts with `draft: true`. Default: only outside production, so drafts show up in
10
+ * `next dev` and never on the published site.
11
+ */
12
+ drafts?: boolean;
13
+ /** File extensions to read. Default: md and markdown. */
14
+ extensions?: string[];
15
+ }
16
+ /** Splits the front matter (YAML between `---`) from the text. */
17
+ export declare function parseMarkdown<T = Record<string, unknown>>(text: string): {
18
+ data: T;
19
+ body: string;
20
+ };
21
+ /**
22
+ * Reads every post in a folder, newest first (by the `pubDate`, `date` or `publishDate` field).
23
+ * The folder is relative to the project root, e.g. `content/blog`.
24
+ */
25
+ export declare function getPosts<T = Record<string, unknown>>(folder: string, options?: ReadOptions): Promise<Post<T>[]>;
26
+ /** Post slugs (the file name without extension). Handy for `generateStaticParams`. */
27
+ export declare function getSlugs(folder: string, options?: ReadOptions): Promise<string[]>;
28
+ /** Reads one post by slug. Returns `null` if it does not exist (or is a draft in production). */
29
+ export declare function getPost<T = Record<string, unknown>>(folder: string, slug: string, options?: ReadOptions): Promise<Post<T> | null>;
@@ -0,0 +1,68 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import YAML from 'yaml';
4
+ const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
5
+ const DATE_FIELDS = ['pubDate', 'date', 'publishDate', 'published_at'];
6
+ const SAFE_SLUG = /^[\w][\w.-]*$/;
7
+ /** Splits the front matter (YAML between `---`) from the text. */
8
+ export function parseMarkdown(text) {
9
+ const match = text.match(FRONTMATTER);
10
+ if (!match) {
11
+ return { data: {}, body: text };
12
+ }
13
+ return {
14
+ data: (YAML.parse(match[1]) ?? {}),
15
+ body: text.slice(match[0].length).replace(/^\r?\n/, ''),
16
+ };
17
+ }
18
+ const showDrafts = (options) => options.drafts ?? process.env.NODE_ENV !== 'production';
19
+ const extensionsOf = (options) => options.extensions ?? ['md', 'markdown'];
20
+ const resolve = (folder) => path.resolve(process.cwd(), folder);
21
+ const isDraft = (data) => data?.draft === true;
22
+ function dateOf(data) {
23
+ for (const field of DATE_FIELDS) {
24
+ const value = data?.[field];
25
+ const time = value instanceof Date ? value.getTime() : typeof value === 'string' ? Date.parse(value) : NaN;
26
+ if (!Number.isNaN(time)) {
27
+ return time;
28
+ }
29
+ }
30
+ return 0;
31
+ }
32
+ /**
33
+ * Reads every post in a folder, newest first (by the `pubDate`, `date` or `publishDate` field).
34
+ * The folder is relative to the project root, e.g. `content/blog`.
35
+ */
36
+ export async function getPosts(folder, options = {}) {
37
+ const extensions = extensionsOf(options);
38
+ const names = (await readdir(resolve(folder))).filter((name) => extensions.includes(path.extname(name).slice(1)));
39
+ const posts = await Promise.all(names.map(async (name) => {
40
+ const { data, body } = parseMarkdown(await readFile(path.join(resolve(folder), name), 'utf8'));
41
+ return { slug: name.slice(0, -path.extname(name).length), data, body };
42
+ }));
43
+ return posts
44
+ .filter((post) => showDrafts(options) || !isDraft(post.data))
45
+ .sort((a, b) => dateOf(b.data) - dateOf(a.data) || a.slug.localeCompare(b.slug));
46
+ }
47
+ /** Post slugs (the file name without extension). Handy for `generateStaticParams`. */
48
+ export async function getSlugs(folder, options = {}) {
49
+ return (await getPosts(folder, options)).map((post) => post.slug);
50
+ }
51
+ /** Reads one post by slug. Returns `null` if it does not exist (or is a draft in production). */
52
+ export async function getPost(folder, slug, options = {}) {
53
+ if (!SAFE_SLUG.test(slug)) {
54
+ return null;
55
+ }
56
+ for (const extension of extensionsOf(options)) {
57
+ let text;
58
+ try {
59
+ text = await readFile(path.join(resolve(folder), `${slug}.${extension}`), 'utf8');
60
+ }
61
+ catch {
62
+ continue;
63
+ }
64
+ const { data, body } = parseMarkdown(text);
65
+ return !showDrafts(options) && isDraft(data) ? null : { slug, data, body };
66
+ }
67
+ return null;
68
+ }
@@ -0,0 +1,2 @@
1
+ export { getPost, getPosts, getSlugs, parseMarkdown, type Post, type ReadOptions } from './content.js';
2
+ export { SapphireContent, type SapphireContentProps } from './SapphireContent.js';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { getPost, getPosts, getSlugs, parseMarkdown } from './content.js';
2
+ export { SapphireContent } from './SapphireContent.js';
package/dist/prose.css ADDED
@@ -0,0 +1,250 @@
1
+ /*
2
+ * Sapphire Blog CMS — reading theme for posts.
3
+ *
4
+ * Usage: wrap the post text in <article class="sapphire-prose">…</article>.
5
+ * Works even with Tailwind's reset (preflight), which strips headings, lists and tables.
6
+ * Everything is inside :where(), so any site rule wins without needing !important.
7
+ *
8
+ * To match your site, override the variables:
9
+ * .sapphire-prose { --sp-accent: #b5651d; --sp-font: Georgia, serif; }
10
+ * Colors derive from the text color (currentColor), so dark mode works on its own.
11
+ */
12
+
13
+ :where(.sapphire-prose) {
14
+ --sp-font: inherit;
15
+ --sp-heading-font: inherit;
16
+ --sp-mono-font: ui-monospace, 'Cascadia Code', 'SFMono-Regular', Menlo, Consolas, monospace;
17
+ --sp-size: 1.0625rem;
18
+ --sp-leading: 1.75;
19
+ --sp-measure: 70ch;
20
+ --sp-accent: #1f4fe0;
21
+ --sp-muted: color-mix(in srgb, currentColor 68%, transparent);
22
+ --sp-border: color-mix(in srgb, currentColor 16%, transparent);
23
+ --sp-soft: color-mix(in srgb, currentColor 6%, transparent);
24
+ --sp-radius: 8px;
25
+
26
+ max-width: var(--sp-measure);
27
+ font-family: var(--sp-font);
28
+ font-size: var(--sp-size);
29
+ line-height: var(--sp-leading);
30
+ overflow-wrap: break-word;
31
+ }
32
+
33
+ :where(.sapphire-prose) > :first-child {
34
+ margin-top: 0;
35
+ }
36
+
37
+ :where(.sapphire-prose) > :last-child {
38
+ margin-bottom: 0;
39
+ }
40
+
41
+ :where(.sapphire-prose) :where(p, ul, ol, blockquote, pre, table, figure) {
42
+ margin: 1.1em 0;
43
+ }
44
+
45
+ /* Headings */
46
+
47
+ :where(.sapphire-prose) :where(h1, h2, h3, h4, h5, h6) {
48
+ margin: 1.8em 0 0.6em;
49
+ font-family: var(--sp-heading-font);
50
+ font-weight: 700;
51
+ line-height: 1.25;
52
+ letter-spacing: -0.01em;
53
+ text-wrap: balance;
54
+ }
55
+
56
+ :where(.sapphire-prose) h1 {
57
+ font-size: 2.1em;
58
+ }
59
+
60
+ :where(.sapphire-prose) h2 {
61
+ font-size: 1.55em;
62
+ }
63
+
64
+ :where(.sapphire-prose) h3 {
65
+ font-size: 1.25em;
66
+ }
67
+
68
+ :where(.sapphire-prose) :where(h4, h5, h6) {
69
+ font-size: 1.05em;
70
+ }
71
+
72
+ :where(.sapphire-prose) :where(h1, h2, h3, h4, h5, h6) + * {
73
+ margin-top: 0;
74
+ }
75
+
76
+ /* Text */
77
+
78
+ :where(.sapphire-prose) a {
79
+ color: var(--sp-accent);
80
+ text-decoration: underline;
81
+ text-decoration-thickness: 1px;
82
+ text-underline-offset: 3px;
83
+ }
84
+
85
+ :where(.sapphire-prose) a:hover {
86
+ text-decoration-thickness: 2px;
87
+ }
88
+
89
+ :where(.sapphire-prose) :where(strong, b) {
90
+ font-weight: 700;
91
+ }
92
+
93
+ :where(.sapphire-prose) :where(em, i) {
94
+ font-style: italic;
95
+ }
96
+
97
+ :where(.sapphire-prose) u {
98
+ text-decoration: underline;
99
+ text-underline-offset: 3px;
100
+ }
101
+
102
+ :where(.sapphire-prose) :where(s, del) {
103
+ text-decoration: line-through;
104
+ }
105
+
106
+ /* Highlight: the color comes from the editor (style="background-color: …"). */
107
+ :where(.sapphire-prose) mark {
108
+ padding: 0.05em 0.2em;
109
+ border-radius: 3px;
110
+ background-color: #fef08a;
111
+ color: inherit;
112
+ box-decoration-break: clone;
113
+ -webkit-box-decoration-break: clone;
114
+ }
115
+
116
+ /* Lists */
117
+
118
+ :where(.sapphire-prose) ul {
119
+ padding-left: 1.5em;
120
+ list-style: disc;
121
+ }
122
+
123
+ :where(.sapphire-prose) ol {
124
+ padding-left: 1.6em;
125
+ list-style: decimal;
126
+ }
127
+
128
+ :where(.sapphire-prose) :where(ul ul) {
129
+ list-style: circle;
130
+ }
131
+
132
+ :where(.sapphire-prose) li {
133
+ margin: 0.35em 0;
134
+ padding-left: 0.25em;
135
+ }
136
+
137
+ :where(.sapphire-prose) li > :where(p) {
138
+ margin: 0.35em 0;
139
+ }
140
+
141
+ :where(.sapphire-prose) li > :where(ul, ol) {
142
+ margin: 0.35em 0;
143
+ }
144
+
145
+ :where(.sapphire-prose) li::marker {
146
+ color: var(--sp-muted);
147
+ }
148
+
149
+ /* Quote */
150
+
151
+ :where(.sapphire-prose) blockquote {
152
+ padding: 0.2em 0 0.2em 1.1em;
153
+ border-left: 3px solid var(--sp-accent);
154
+ color: var(--sp-muted);
155
+ font-style: italic;
156
+ }
157
+
158
+ /* Images and videos */
159
+
160
+ :where(.sapphire-prose) img {
161
+ display: block;
162
+ max-width: 100%;
163
+ height: auto;
164
+ margin: 1.6em auto;
165
+ border-radius: var(--sp-radius);
166
+ }
167
+
168
+ :where(.sapphire-prose) figure img {
169
+ margin-bottom: 0.5em;
170
+ }
171
+
172
+ :where(.sapphire-prose) figcaption {
173
+ color: var(--sp-muted);
174
+ font-size: 0.875em;
175
+ text-align: center;
176
+ }
177
+
178
+ :where(.sapphire-prose) :where(iframe, video) {
179
+ display: block;
180
+ width: 100%;
181
+ max-width: 100%;
182
+ height: auto;
183
+ aspect-ratio: 16 / 9;
184
+ margin: 1.6em auto;
185
+ border: 0;
186
+ border-radius: var(--sp-radius);
187
+ }
188
+
189
+ /* Code */
190
+
191
+ :where(.sapphire-prose) code {
192
+ padding: 0.15em 0.35em;
193
+ border-radius: 5px;
194
+ background: var(--sp-soft);
195
+ font-family: var(--sp-mono-font);
196
+ font-size: 0.875em;
197
+ }
198
+
199
+ :where(.sapphire-prose) pre {
200
+ padding: 1em 1.2em;
201
+ overflow-x: auto;
202
+ border: 1px solid var(--sp-border);
203
+ border-radius: var(--sp-radius);
204
+ background: var(--sp-soft);
205
+ font-size: 0.875em;
206
+ line-height: 1.6;
207
+ }
208
+
209
+ :where(.sapphire-prose) pre code {
210
+ padding: 0;
211
+ background: none;
212
+ font-size: inherit;
213
+ }
214
+
215
+ /* Divider */
216
+
217
+ :where(.sapphire-prose) hr {
218
+ height: 0;
219
+ margin: 2.5em 0;
220
+ border: 0;
221
+ border-top: 1px solid var(--sp-border);
222
+ }
223
+
224
+ /* Tables: scroll horizontally on phones instead of overflowing the page. */
225
+
226
+ :where(.sapphire-prose) table {
227
+ display: block;
228
+ width: max-content;
229
+ max-width: 100%;
230
+ overflow-x: auto;
231
+ border-collapse: collapse;
232
+ font-size: 0.95em;
233
+ }
234
+
235
+ :where(.sapphire-prose) :where(th, td) {
236
+ padding: 0.55em 0.9em;
237
+ border: 1px solid var(--sp-border);
238
+ text-align: left;
239
+ vertical-align: top;
240
+ }
241
+
242
+ :where(.sapphire-prose) th {
243
+ background: var(--sp-soft);
244
+ font-weight: 600;
245
+ }
246
+
247
+ /* Editor protected block (HTML it does not edit): shown as written. */
248
+ :where(.sapphire-prose) :where(div, section, aside) {
249
+ max-width: 100%;
250
+ }
package/package.json CHANGED
@@ -1,6 +1,53 @@
1
1
  {
2
2
  "name": "@sapphirelabs/blog-cms-next",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Sapphire Blog CMS for Next.js: CMS panel at /admin, post loading helpers and a component that renders the text.",
5
+ "license": "MIT",
6
+ "author": "Sapphire Labs (https://github.com/sapphire-labs-dev)",
7
+ "homepage": "https://github.com/sapphire-labs-dev/sapphire-blog-cms#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/sapphire-labs-dev/sapphire-blog-cms.git",
11
+ "directory": "packages/next"
12
+ },
13
+ "bugs": "https://github.com/sapphire-labs-dev/sapphire-blog-cms/issues",
14
+ "type": "module",
15
+ "exports": {
16
+ ".": "./dist/index.js",
17
+ "./admin": "./dist/admin.js",
18
+ "./prose.css": "./dist/prose.css"
19
+ },
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "keywords": [
24
+ "nextjs",
25
+ "cms",
26
+ "markdown"
27
+ ],
28
+ "peerDependencies": {
29
+ "next": ">=14",
30
+ "react": ">=18"
31
+ },
32
+ "dependencies": {
33
+ "react-markdown": "^10.1.0",
34
+ "rehype-raw": "^7.0.0",
35
+ "rehype-sanitize": "^6.0.0",
36
+ "remark-gfm": "^4.0.1",
37
+ "yaml": "^2.9.1"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^26.6.6",
41
+ "@types/react": "^19.3.0",
42
+ "next": "^16.4.6",
43
+ "react": "^19.3.1",
44
+ "react-dom": "^19.3.1",
45
+ "typescript": "^7.0.2",
46
+ "vitest": "^5.1.0"
47
+ },
48
+ "scripts": {
49
+ "build": "tsc -p . && node -e \"require('node:fs').copyFileSync('../app/public/prose.css', 'dist/prose.css')\"",
50
+ "test": "vitest run",
51
+ "typecheck": "tsc --noEmit -p ."
52
+ }
6
53
  }