create-starlight-cms 0.9.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 +21 -0
- package/README.md +18 -0
- package/THIRD_PARTY_NOTICES.md +4 -0
- package/bin/index.js +147 -0
- package/package.json +46 -0
- package/template/.nvmrc +1 -0
- package/template/LICENSE +21 -0
- package/template/README.md +68 -0
- package/template/THIRD_PARTY_NOTICES.md +12 -0
- package/template/_gitignore +14 -0
- package/template/astro.config.mjs +26 -0
- package/template/migrations/0001_schema.sql +73 -0
- package/template/migrations/0002_publish_delivery.sql +16 -0
- package/template/package-lock.json.template +9123 -0
- package/template/package.json +89 -0
- package/template/scripts/build-admin.mjs +38 -0
- package/template/scripts/check-linux-bindings.mjs +18 -0
- package/template/scripts/dev.mjs +94 -0
- package/template/src/admin/admin.scss +182 -0
- package/template/src/admin/app.ts +130 -0
- package/template/src/admin/client.tsx +684 -0
- package/template/src/admin/components/tiptap-extension/node-background-extension.ts +150 -0
- package/template/src/admin/components/tiptap-icons/align-center-icon.tsx +38 -0
- package/template/src/admin/components/tiptap-icons/align-justify-icon.tsx +38 -0
- package/template/src/admin/components/tiptap-icons/align-left-icon.tsx +38 -0
- package/template/src/admin/components/tiptap-icons/align-right-icon.tsx +38 -0
- package/template/src/admin/components/tiptap-icons/arrow-left-icon.tsx +24 -0
- package/template/src/admin/components/tiptap-icons/arrow-right-icon.tsx +26 -0
- package/template/src/admin/components/tiptap-icons/ban-icon.tsx +26 -0
- package/template/src/admin/components/tiptap-icons/blockquote-icon.tsx +44 -0
- package/template/src/admin/components/tiptap-icons/bold-icon.tsx +26 -0
- package/template/src/admin/components/tiptap-icons/case-sensitive-icon.tsx +32 -0
- package/template/src/admin/components/tiptap-icons/check-icon.tsx +26 -0
- package/template/src/admin/components/tiptap-icons/chevron-down-icon.tsx +26 -0
- package/template/src/admin/components/tiptap-icons/chevron-up-icon.tsx +22 -0
- package/template/src/admin/components/tiptap-icons/close-icon.tsx +24 -0
- package/template/src/admin/components/tiptap-icons/code-block-icon.tsx +38 -0
- package/template/src/admin/components/tiptap-icons/code2-icon.tsx +32 -0
- package/template/src/admin/components/tiptap-icons/corner-down-left-icon.tsx +26 -0
- package/template/src/admin/components/tiptap-icons/external-link-icon.tsx +28 -0
- package/template/src/admin/components/tiptap-icons/heading-five-icon.tsx +28 -0
- package/template/src/admin/components/tiptap-icons/heading-four-icon.tsx +28 -0
- package/template/src/admin/components/tiptap-icons/heading-icon.tsx +24 -0
- package/template/src/admin/components/tiptap-icons/heading-one-icon.tsx +28 -0
- package/template/src/admin/components/tiptap-icons/heading-six-icon.tsx +30 -0
- package/template/src/admin/components/tiptap-icons/heading-three-icon.tsx +36 -0
- package/template/src/admin/components/tiptap-icons/heading-two-icon.tsx +28 -0
- package/template/src/admin/components/tiptap-icons/highlighter-icon.tsx +26 -0
- package/template/src/admin/components/tiptap-icons/image-plus-icon.tsx +26 -0
- package/template/src/admin/components/tiptap-icons/italic-icon.tsx +24 -0
- package/template/src/admin/components/tiptap-icons/link-icon.tsx +28 -0
- package/template/src/admin/components/tiptap-icons/list-icon.tsx +56 -0
- package/template/src/admin/components/tiptap-icons/list-ordered-icon.tsx +56 -0
- package/template/src/admin/components/tiptap-icons/list-todo-icon.tsx +50 -0
- package/template/src/admin/components/tiptap-icons/moon-star-icon.tsx +30 -0
- package/template/src/admin/components/tiptap-icons/redo2-icon.tsx +26 -0
- package/template/src/admin/components/tiptap-icons/search-icon.tsx +26 -0
- package/template/src/admin/components/tiptap-icons/strike-icon.tsx +28 -0
- package/template/src/admin/components/tiptap-icons/subscript-icon.tsx +38 -0
- package/template/src/admin/components/tiptap-icons/sun-icon.tsx +58 -0
- package/template/src/admin/components/tiptap-icons/superscript-icon.tsx +38 -0
- package/template/src/admin/components/tiptap-icons/trash-icon.tsx +26 -0
- package/template/src/admin/components/tiptap-icons/underline-icon.tsx +26 -0
- package/template/src/admin/components/tiptap-icons/undo2-icon.tsx +26 -0
- package/template/src/admin/components/tiptap-icons/whole-word-icon.tsx +36 -0
- package/template/src/admin/components/tiptap-node/blockquote-node/blockquote-node.scss +37 -0
- package/template/src/admin/components/tiptap-node/code-block-node/code-block-node.scss +54 -0
- package/template/src/admin/components/tiptap-node/heading-node/heading-node.scss +45 -0
- package/template/src/admin/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension.ts +14 -0
- package/template/src/admin/components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss +25 -0
- package/template/src/admin/components/tiptap-node/image-node/image-node.scss +35 -0
- package/template/src/admin/components/tiptap-node/image-upload-node/image-upload-node-extension.ts +161 -0
- package/template/src/admin/components/tiptap-node/image-upload-node/image-upload-node.scss +249 -0
- package/template/src/admin/components/tiptap-node/image-upload-node/image-upload-node.tsx +554 -0
- package/template/src/admin/components/tiptap-node/image-upload-node/index.tsx +1 -0
- package/template/src/admin/components/tiptap-node/list-node/list-node.scss +208 -0
- package/template/src/admin/components/tiptap-node/paragraph-node/paragraph-node.scss +217 -0
- package/template/src/admin/components/tiptap-templates/simple/NOTICE.md +14 -0
- package/template/src/admin/components/tiptap-templates/simple/data/content.json +477 -0
- package/template/src/admin/components/tiptap-templates/simple/simple-editor.scss +97 -0
- package/template/src/admin/components/tiptap-templates/simple/simple-editor.tsx +348 -0
- package/template/src/admin/components/tiptap-templates/simple/theme-toggle.tsx +44 -0
- package/template/src/admin/components/tiptap-ui/blockquote-button/blockquote-button.tsx +122 -0
- package/template/src/admin/components/tiptap-ui/blockquote-button/index.tsx +2 -0
- package/template/src/admin/components/tiptap-ui/blockquote-button/use-blockquote.ts +273 -0
- package/template/src/admin/components/tiptap-ui/code-block-button/code-block-button.tsx +122 -0
- package/template/src/admin/components/tiptap-ui/code-block-button/index.tsx +2 -0
- package/template/src/admin/components/tiptap-ui/code-block-button/use-code-block.ts +283 -0
- package/template/src/admin/components/tiptap-ui/color-highlight-button/color-highlight-button.scss +49 -0
- package/template/src/admin/components/tiptap-ui/color-highlight-button/color-highlight-button.tsx +170 -0
- package/template/src/admin/components/tiptap-ui/color-highlight-button/index.tsx +2 -0
- package/template/src/admin/components/tiptap-ui/color-highlight-button/use-color-highlight.ts +380 -0
- package/template/src/admin/components/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx +231 -0
- package/template/src/admin/components/tiptap-ui/color-highlight-popover/index.tsx +1 -0
- package/template/src/admin/components/tiptap-ui/heading-button/heading-button.tsx +124 -0
- package/template/src/admin/components/tiptap-ui/heading-button/index.tsx +2 -0
- package/template/src/admin/components/tiptap-ui/heading-button/use-heading.ts +348 -0
- package/template/src/admin/components/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx +128 -0
- package/template/src/admin/components/tiptap-ui/heading-dropdown-menu/index.tsx +2 -0
- package/template/src/admin/components/tiptap-ui/heading-dropdown-menu/use-heading-dropdown-menu.ts +132 -0
- package/template/src/admin/components/tiptap-ui/image-upload-button/image-upload-button.tsx +130 -0
- package/template/src/admin/components/tiptap-ui/image-upload-button/index.tsx +2 -0
- package/template/src/admin/components/tiptap-ui/image-upload-button/use-image-upload.ts +197 -0
- package/template/src/admin/components/tiptap-ui/link-popover/index.tsx +2 -0
- package/template/src/admin/components/tiptap-ui/link-popover/link-popover.scss +16 -0
- package/template/src/admin/components/tiptap-ui/link-popover/link-popover.tsx +315 -0
- package/template/src/admin/components/tiptap-ui/link-popover/use-link-popover.ts +297 -0
- package/template/src/admin/components/tiptap-ui/list-button/index.tsx +2 -0
- package/template/src/admin/components/tiptap-ui/list-button/list-button.tsx +120 -0
- package/template/src/admin/components/tiptap-ui/list-button/use-list.ts +353 -0
- package/template/src/admin/components/tiptap-ui/list-dropdown-menu/index.tsx +2 -0
- package/template/src/admin/components/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx +126 -0
- package/template/src/admin/components/tiptap-ui/list-dropdown-menu/use-list-dropdown-menu.ts +220 -0
- package/template/src/admin/components/tiptap-ui/mark-button/index.tsx +2 -0
- package/template/src/admin/components/tiptap-ui/mark-button/mark-button.tsx +122 -0
- package/template/src/admin/components/tiptap-ui/mark-button/use-mark.ts +219 -0
- package/template/src/admin/components/tiptap-ui/search-and-replace/index.tsx +2 -0
- package/template/src/admin/components/tiptap-ui/search-and-replace/search-and-replace.scss +300 -0
- package/template/src/admin/components/tiptap-ui/search-and-replace/search-and-replace.tsx +718 -0
- package/template/src/admin/components/tiptap-ui/search-and-replace/use-search-and-replace.ts +528 -0
- package/template/src/admin/components/tiptap-ui/text-align-button/index.tsx +2 -0
- package/template/src/admin/components/tiptap-ui/text-align-button/text-align-button.tsx +144 -0
- package/template/src/admin/components/tiptap-ui/text-align-button/use-text-align.ts +237 -0
- package/template/src/admin/components/tiptap-ui/undo-redo-button/index.tsx +2 -0
- package/template/src/admin/components/tiptap-ui/undo-redo-button/undo-redo-button.tsx +125 -0
- package/template/src/admin/components/tiptap-ui/undo-redo-button/use-undo-redo.ts +200 -0
- package/template/src/admin/components/tiptap-ui-primitive/badge/badge-colors.scss +395 -0
- package/template/src/admin/components/tiptap-ui-primitive/badge/badge-group.scss +16 -0
- package/template/src/admin/components/tiptap-ui-primitive/badge/badge.scss +99 -0
- package/template/src/admin/components/tiptap-ui-primitive/badge/badge.tsx +44 -0
- package/template/src/admin/components/tiptap-ui-primitive/badge/index.tsx +1 -0
- package/template/src/admin/components/tiptap-ui-primitive/button/button-colors.scss +613 -0
- package/template/src/admin/components/tiptap-ui-primitive/button/button.scss +390 -0
- package/template/src/admin/components/tiptap-ui-primitive/button/button.tsx +158 -0
- package/template/src/admin/components/tiptap-ui-primitive/button/index.tsx +1 -0
- package/template/src/admin/components/tiptap-ui-primitive/button-group/button-group.scss +89 -0
- package/template/src/admin/components/tiptap-ui-primitive/button-group/button-group.tsx +72 -0
- package/template/src/admin/components/tiptap-ui-primitive/button-group/index.tsx +1 -0
- package/template/src/admin/components/tiptap-ui-primitive/card/card.scss +74 -0
- package/template/src/admin/components/tiptap-ui-primitive/card/card.tsx +79 -0
- package/template/src/admin/components/tiptap-ui-primitive/card/index.tsx +1 -0
- package/template/src/admin/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss +152 -0
- package/template/src/admin/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.tsx +262 -0
- package/template/src/admin/components/tiptap-ui-primitive/dropdown-menu/index.tsx +1 -0
- package/template/src/admin/components/tiptap-ui-primitive/input/index.tsx +1 -0
- package/template/src/admin/components/tiptap-ui-primitive/input/input.scss +41 -0
- package/template/src/admin/components/tiptap-ui-primitive/input/input.tsx +22 -0
- package/template/src/admin/components/tiptap-ui-primitive/input-group/index.tsx +1 -0
- package/template/src/admin/components/tiptap-ui-primitive/input-group/input-group.scss +225 -0
- package/template/src/admin/components/tiptap-ui-primitive/input-group/input-group.tsx +135 -0
- package/template/src/admin/components/tiptap-ui-primitive/popover/index.tsx +1 -0
- package/template/src/admin/components/tiptap-ui-primitive/popover/popover.scss +64 -0
- package/template/src/admin/components/tiptap-ui-primitive/popover/popover.tsx +35 -0
- package/template/src/admin/components/tiptap-ui-primitive/separator/index.tsx +1 -0
- package/template/src/admin/components/tiptap-ui-primitive/separator/separator.scss +23 -0
- package/template/src/admin/components/tiptap-ui-primitive/separator/separator.tsx +28 -0
- package/template/src/admin/components/tiptap-ui-primitive/spacer/index.tsx +1 -0
- package/template/src/admin/components/tiptap-ui-primitive/spacer/spacer.tsx +24 -0
- package/template/src/admin/components/tiptap-ui-primitive/switch/index.tsx +1 -0
- package/template/src/admin/components/tiptap-ui-primitive/switch/switch.scss +86 -0
- package/template/src/admin/components/tiptap-ui-primitive/switch/switch.tsx +29 -0
- package/template/src/admin/components/tiptap-ui-primitive/textarea/index.tsx +1 -0
- package/template/src/admin/components/tiptap-ui-primitive/textarea/textarea.scss +42 -0
- package/template/src/admin/components/tiptap-ui-primitive/textarea/textarea.tsx +16 -0
- package/template/src/admin/components/tiptap-ui-primitive/toolbar/index.tsx +1 -0
- package/template/src/admin/components/tiptap-ui-primitive/toolbar/toolbar.scss +98 -0
- package/template/src/admin/components/tiptap-ui-primitive/toolbar/toolbar.tsx +203 -0
- package/template/src/admin/components/tiptap-ui-primitive/tooltip/index.tsx +1 -0
- package/template/src/admin/components/tiptap-ui-primitive/tooltip/tooltip.scss +43 -0
- package/template/src/admin/components/tiptap-ui-primitive/tooltip/tooltip.tsx +241 -0
- package/template/src/admin/hooks/use-composed-ref.ts +46 -0
- package/template/src/admin/hooks/use-cursor-visibility.ts +69 -0
- package/template/src/admin/hooks/use-element-rect.ts +166 -0
- package/template/src/admin/hooks/use-is-breakpoint.ts +35 -0
- package/template/src/admin/hooks/use-menu-navigation.ts +204 -0
- package/template/src/admin/hooks/use-scrolling.ts +75 -0
- package/template/src/admin/hooks/use-throttled-callback.ts +48 -0
- package/template/src/admin/hooks/use-tiptap-editor.ts +69 -0
- package/template/src/admin/hooks/use-unmount.ts +21 -0
- package/template/src/admin/hooks/use-window-size.ts +93 -0
- package/template/src/admin/html.ts +17 -0
- package/template/src/admin/lib/tiptap-utils.ts +652 -0
- package/template/src/admin/navigation-tree.tsx +82 -0
- package/template/src/admin/preview.ts +73 -0
- package/template/src/admin/styles/_cms-theme.scss +58 -0
- package/template/src/admin/styles/_keyframe-animations.scss +91 -0
- package/template/src/admin/styles/_variables.scss +298 -0
- package/template/src/assets/favicon.svg +1 -0
- package/template/src/assets/logo.svg +1 -0
- package/template/src/astro-env.d.ts +1 -0
- package/template/src/content.config.ts +12 -0
- package/template/src/db/bootstrap.ts +36 -0
- package/template/src/db/client.ts +7 -0
- package/template/src/db/schema.ts +45 -0
- package/template/src/documents/service.ts +178 -0
- package/template/src/documents/validation.ts +9 -0
- package/template/src/env.ts +9 -0
- package/template/src/index.ts +10 -0
- package/template/src/locales.ts +10 -0
- package/template/src/media/service.ts +120 -0
- package/template/src/navigation/service.ts +171 -0
- package/template/src/pages/[locale]/cms-preview-shell.astro +12 -0
- package/template/src/pages/cms-preview-shell.astro +5 -0
- package/template/src/publish/deploy-hook.ts +42 -0
- package/template/src/publish/record.ts +20 -0
- package/template/src/publish/service.ts +135 -0
- package/template/src/scss.d.ts +5 -0
- package/template/src/site.config.ts +7 -0
- package/template/src/sql.d.ts +4 -0
- package/template/src/starlight/cms-origin.ts +4 -0
- package/template/src/starlight/loader.ts +21 -0
- package/template/src/starlight/preview-render.ts +162 -0
- package/template/src/starlight/preview-shell.astro +14 -0
- package/template/src/starlight/render.ts +59 -0
- package/template/src/starlight/schema.ts +34 -0
- package/template/src/starlight/sidebar.ts +35 -0
- package/template/src/starlight/snapshot.ts +47 -0
- package/template/src/starlight/source.ts +26 -0
- package/template/src/starlight/tiptap.ts +29 -0
- package/template/src/styles/starlight.css +12 -0
- package/template/test-files/content.test.template.ts +82 -0
- package/template/test-files/deploy-hook.test.template.ts +32 -0
- package/template/test-files/media.test.template.ts +39 -0
- package/template/test-files/publishing.integration.test.template.ts +156 -0
- package/template/tsconfig.admin.json +18 -0
- package/template/tsconfig.json +23 -0
- package/template/worker-configuration.d.ts +15389 -0
- package/template/wrangler.jsonc +26 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { documentUrl, parseTiptapNode, tiptapChildren, type TiptapNode } from './tiptap.ts';
|
|
2
|
+
|
|
3
|
+
function decorateText(value: TiptapNode, output: string): string {
|
|
4
|
+
const marks = Array.isArray(value.marks) ? value.marks : [];
|
|
5
|
+
for (const mark of marks) {
|
|
6
|
+
if (!mark || typeof mark !== 'object' || !('type' in mark)) continue;
|
|
7
|
+
const typed = mark as { type: unknown; attrs?: Record<string, unknown> };
|
|
8
|
+
if (typed.type === 'bold') output = `**${output}**`;
|
|
9
|
+
if (typed.type === 'italic') output = `*${output}*`;
|
|
10
|
+
if (typed.type === 'strike') output = `~~${output}~~`;
|
|
11
|
+
if (typed.type === 'code') output = `\`${output}\``;
|
|
12
|
+
if (typed.type === 'link') output = `[${output}](${documentUrl(typed.attrs?.href)})`;
|
|
13
|
+
}
|
|
14
|
+
return output;
|
|
15
|
+
}
|
|
16
|
+
function text(value: TiptapNode): string { return decorateText(value, tiptapChildren(value).map(render).join('')); }
|
|
17
|
+
function asideTitle(value: unknown): string {
|
|
18
|
+
return typeof value === 'string' ? value.replace(/[\]\r\n]/g, ' ').trim() : 'Note';
|
|
19
|
+
}
|
|
20
|
+
export function render(nodeValue: unknown): string {
|
|
21
|
+
const value = parseTiptapNode(nodeValue);
|
|
22
|
+
switch (value.type) {
|
|
23
|
+
case 'text': return decorateText(value, value.text ?? '');
|
|
24
|
+
case 'doc': return `${tiptapChildren(value).map(render).join('\n\n')}\n`;
|
|
25
|
+
case 'paragraph': return text(value);
|
|
26
|
+
case 'heading': return `${'#'.repeat(Number(value.attrs?.level ?? 2))} ${text(value)}`;
|
|
27
|
+
case 'bulletList': return tiptapChildren(value).map((item) => `- ${text(item)}`).join('\n');
|
|
28
|
+
case 'orderedList': return tiptapChildren(value).map((item, index) => `${index + 1}. ${text(item)}`).join('\n');
|
|
29
|
+
case 'taskList': return tiptapChildren(value).map((item) => `- [${item.attrs?.checked === true ? 'x' : ' '}] ${text(item)}`).join('\n');
|
|
30
|
+
case 'taskItem': return text(value);
|
|
31
|
+
case 'listItem': return text(value);
|
|
32
|
+
case 'blockquote': return text(value).split('\n').map((line) => `> ${line}`).join('\n');
|
|
33
|
+
case 'codeBlock': return `\`\`\`${typeof value.attrs?.language === 'string' ? value.attrs.language : ''}\n${text(value)}\n\`\`\``;
|
|
34
|
+
case 'hardBreak': return ' \n';
|
|
35
|
+
case 'horizontalRule': return '---';
|
|
36
|
+
case 'image': return `})`;
|
|
37
|
+
case 'video': return `<video controls src="${documentUrl(value.attrs?.src)}"></video>`;
|
|
38
|
+
case 'callout': return `:::note[${asideTitle(value.attrs?.title)}]\n${tiptapChildren(value).map(render).join('\n\n')}\n:::`;
|
|
39
|
+
case 'steps': return tiptapChildren(value).map((item, index) => `${index + 1}. ${text(item)}`).join('\n');
|
|
40
|
+
case 'tabs': return tiptapChildren(value).map((item) => `#### ${typeof item.attrs?.label === 'string' ? item.attrs.label : 'Tab'}\n\n${text(item)}`).join('\n\n');
|
|
41
|
+
case 'tab': return text(value);
|
|
42
|
+
case 'table': {
|
|
43
|
+
const rows = tiptapChildren(value);
|
|
44
|
+
return rows.map((row, index) => {
|
|
45
|
+
const cells = tiptapChildren(row);
|
|
46
|
+
const markdown = `| ${cells.map(text).join(' | ')} |`;
|
|
47
|
+
return index === 0 ? `${markdown}\n| ${cells.map(() => '---').join(' | ')} |` : markdown;
|
|
48
|
+
}).join('\n');
|
|
49
|
+
}
|
|
50
|
+
case 'tableRow': return `| ${tiptapChildren(value).map(text).join(' | ')} |`;
|
|
51
|
+
case 'tableHeader':
|
|
52
|
+
case 'tableCell': return text(value);
|
|
53
|
+
default: throw new Error(`Unsupported Tiptap node: ${value.type}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function renderDocumentContent(content: unknown): string {
|
|
58
|
+
return render(content);
|
|
59
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { defaultLocale, supportedLocales } from '../locales.ts';
|
|
3
|
+
|
|
4
|
+
export const documentSchema = z.object({
|
|
5
|
+
id: z.string().min(1),
|
|
6
|
+
locale: z.enum(supportedLocales).default(defaultLocale),
|
|
7
|
+
title: z.string().min(1),
|
|
8
|
+
slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*$/)
|
|
9
|
+
.refine((slug) => !['admin', 'api', 'auth', '_astro', 'pagefind', '404'].includes(slug.split('/')[0]!), 'Reserved route'),
|
|
10
|
+
description: z.string().default(''),
|
|
11
|
+
section: z.string().default(''),
|
|
12
|
+
navigation: z.array(z.object({ slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), label: z.string().min(1), order: z.number().int().nonnegative(), translations: z.record(z.string(), z.string()) })).default([]),
|
|
13
|
+
order: z.number().int().nonnegative().default(0),
|
|
14
|
+
body: z.object({ format: z.literal('markdown'), value: z.string() }).strict(),
|
|
15
|
+
status: z.literal('published'),
|
|
16
|
+
createdAt: z.string().datetime(),
|
|
17
|
+
updatedAt: z.string().datetime(),
|
|
18
|
+
publishedAt: z.string().datetime(),
|
|
19
|
+
}).strict();
|
|
20
|
+
export const snapshotSchema = z.object({ version: z.union([z.literal(1), z.literal(2), z.literal(3)]), documents: z.array(documentSchema) }).strict();
|
|
21
|
+
export type Document = z.infer<typeof documentSchema>;
|
|
22
|
+
|
|
23
|
+
export function publishedDocuments(input: unknown): Document[] {
|
|
24
|
+
const { documents } = snapshotSchema.parse(input);
|
|
25
|
+
const ids = new Set<string>(); const routes = new Set<string>();
|
|
26
|
+
for (const doc of documents) {
|
|
27
|
+
if (doc.locale === defaultLocale && supportedLocales.some((locale) => locale !== defaultLocale && doc.slug.split('/')[0] === locale)) throw new Error('Published route conflicts with a language prefix');
|
|
28
|
+
const id = `${doc.locale}:${doc.id}`;
|
|
29
|
+
const route = doc.locale === defaultLocale ? doc.slug : `${doc.locale}/${doc.slug}`;
|
|
30
|
+
if (ids.has(id) || routes.has(route)) throw new Error('Duplicate published id or route');
|
|
31
|
+
ids.add(id); routes.add(route);
|
|
32
|
+
}
|
|
33
|
+
return documents.sort((a, b) => a.locale.localeCompare(b.locale) || a.order - b.order || a.slug.localeCompare(b.slug));
|
|
34
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { defaultLocale } from '../locales.ts';
|
|
2
|
+
import { publishedDocuments } from './schema.ts';
|
|
3
|
+
|
|
4
|
+
type Entry = { label: string; translations?: Record<string, string>; slug?: string; items?: Entry[] };
|
|
5
|
+
type OrderedEntry = Entry & { order: number; key: string; items?: OrderedEntry[] };
|
|
6
|
+
|
|
7
|
+
/** Convert the shared CMS hierarchy into Starlight's built-in sidebar configuration. */
|
|
8
|
+
export function cmsSidebar(snapshot: unknown): Entry[] {
|
|
9
|
+
const documents = publishedDocuments(snapshot);
|
|
10
|
+
const roots: OrderedEntry[] = [];
|
|
11
|
+
const pages = new Map<string, typeof documents>();
|
|
12
|
+
for (const doc of documents) pages.set(doc.id, [...(pages.get(doc.id) ?? []), doc]);
|
|
13
|
+
for (const translations of pages.values()) {
|
|
14
|
+
const page = translations.find((doc) => doc.locale === defaultLocale) ?? translations[0]!;
|
|
15
|
+
let items = roots;
|
|
16
|
+
for (const folder of page.navigation) {
|
|
17
|
+
let group = items.find((entry) => entry.key === folder.slug && entry.items);
|
|
18
|
+
if (!group) {
|
|
19
|
+
group = { key: folder.slug, label: folder.label, translations: folder.translations, order: folder.order, items: [] };
|
|
20
|
+
items.push(group);
|
|
21
|
+
}
|
|
22
|
+
items = group.items!;
|
|
23
|
+
}
|
|
24
|
+
items.push({ key: page.slug, label: page.title, order: page.order,
|
|
25
|
+
slug: page.locale === defaultLocale ? page.slug : `${page.locale}/${page.slug}`,
|
|
26
|
+
translations: Object.fromEntries(translations.map((doc) => [doc.locale, doc.title])),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function sorted(items: OrderedEntry[]): Entry[] {
|
|
30
|
+
return items.sort((a, b) => a.order - b.order || a.key.localeCompare(b.key)).map(({ key, order, items, ...entry }) => ({
|
|
31
|
+
...entry, ...(items ? { items: sorted(items) } : {}),
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
return sorted(roots);
|
|
35
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { RuntimeEnv } from '../env.ts';
|
|
3
|
+
import { defaultLocale } from '../locales.ts';
|
|
4
|
+
import { renderDocumentContent } from './render.ts';
|
|
5
|
+
|
|
6
|
+
const folderRow = z.object({ id: z.string(), parent_id: z.string().nullable(), slug: z.string(), sort_order: z.number() });
|
|
7
|
+
type Folder = z.infer<typeof folderRow>;
|
|
8
|
+
const labelRow = z.object({ folder_id: z.string(), locale: z.string(), name: z.string() });
|
|
9
|
+
const pageRow = z.object({ id: z.string(), folder_id: z.string().nullable(), slug: z.string(), sort_order: z.number(), locale: z.string(), title: z.string(), description: z.string(), content_json: z.string(), created_at: z.number(), updated_at: z.number(), published_at: z.number() });
|
|
10
|
+
|
|
11
|
+
export async function publishedSnapshot(env: RuntimeEnv) {
|
|
12
|
+
const [folders, labels, pages] = await env.DB.batch([
|
|
13
|
+
env.DB.prepare('SELECT id,parent_id,slug,sort_order FROM folder ORDER BY id'),
|
|
14
|
+
env.DB.prepare('SELECT folder_id,locale,name FROM folder_translation ORDER BY folder_id,locale'),
|
|
15
|
+
env.DB.prepare(`SELECT d.id,d.folder_id,d.slug,d.sort_order,t.locale,r.title,r.description,r.content_json,
|
|
16
|
+
t.created_at,r.created_at AS updated_at,t.published_at
|
|
17
|
+
FROM document d JOIN document_translation t ON t.document_id=d.id
|
|
18
|
+
JOIN document_revision r ON r.id=t.published_revision_id
|
|
19
|
+
ORDER BY t.locale,d.sort_order,d.slug,d.id`),
|
|
20
|
+
]);
|
|
21
|
+
if (!folders || !labels || !pages) throw new Error('Incomplete published snapshot');
|
|
22
|
+
const parent = new Map(z.array(folderRow).parse(folders.results).map((row) => [row.id, row]));
|
|
23
|
+
const names = z.array(labelRow).parse(labels.results);
|
|
24
|
+
return { version: 3, documents: z.array(pageRow).parse(pages.results).map((row) => {
|
|
25
|
+
const ancestors: Folder[] = []; const seen = new Set<string>(); let cursor = row.folder_id;
|
|
26
|
+
while (cursor) {
|
|
27
|
+
if (seen.has(cursor)) throw new Error('Navigation cycle');
|
|
28
|
+
seen.add(cursor);
|
|
29
|
+
const folder = parent.get(cursor);
|
|
30
|
+
if (!folder) throw new Error('Missing folder');
|
|
31
|
+
ancestors.unshift(folder); cursor = folder.parent_id;
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
id: row.id, locale: row.locale, title: row.title,
|
|
35
|
+
slug: [...ancestors.map((folder) => folder.slug), row.slug].join('/'),
|
|
36
|
+
description: row.description, order: row.sort_order,
|
|
37
|
+
navigation: ancestors.map((folder) => ({
|
|
38
|
+
slug: folder.slug, order: folder.sort_order,
|
|
39
|
+
label: names.find((name) => name.folder_id === folder.id && name.locale === defaultLocale)?.name ?? folder.slug,
|
|
40
|
+
translations: Object.fromEntries(names.filter((name) => name.folder_id === folder.id).map((name) => [name.locale, name.name])),
|
|
41
|
+
})),
|
|
42
|
+
body: { format: 'markdown', value: renderDocumentContent(JSON.parse(row.content_json)) },
|
|
43
|
+
status: 'published', createdAt: new Date(row.created_at).toISOString(),
|
|
44
|
+
updatedAt: new Date(row.updated_at).toISOString(), publishedAt: new Date(row.published_at).toISOString(),
|
|
45
|
+
};
|
|
46
|
+
}) };
|
|
47
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { cmsOrigin } from './cms-origin.ts';
|
|
2
|
+
|
|
3
|
+
async function fetchSnapshot(): Promise<unknown> {
|
|
4
|
+
if (process.env.CMS_INITIAL_EMPTY === '1') return { version: 3, documents: [] };
|
|
5
|
+
const endpoint = process.env.CMS_EXPORT_URL
|
|
6
|
+
?? (cmsOrigin ? new URL('/admin/export/snapshot', cmsOrigin).toString() : undefined);
|
|
7
|
+
// A blank URL is the template's explicit bootstrap state. Once configured,
|
|
8
|
+
// all build failures stay visible instead of publishing an empty fallback.
|
|
9
|
+
if (!endpoint) return { version: 3, documents: [] };
|
|
10
|
+
const clientId = process.env.CF_ACCESS_CLIENT_ID;
|
|
11
|
+
const clientSecret = process.env.CF_ACCESS_CLIENT_SECRET;
|
|
12
|
+
if (Boolean(clientId) !== Boolean(clientSecret)) throw new Error('CF_ACCESS_CLIENT_ID and CF_ACCESS_CLIENT_SECRET must be set together');
|
|
13
|
+
const headers = clientId && clientSecret
|
|
14
|
+
? { 'CF-Access-Client-Id': clientId, 'CF-Access-Client-Secret': clientSecret }
|
|
15
|
+
: undefined;
|
|
16
|
+
const response = await fetch(endpoint, { headers, redirect: 'error', signal: AbortSignal.timeout(30_000) });
|
|
17
|
+
if (!response.ok) throw new Error(`CMS export failed: ${response.status}`);
|
|
18
|
+
return response.json();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
// Astro config and the content loader must use the same export, even when bundled separately.
|
|
23
|
+
declare global { var cmsBuildSnapshot: Promise<unknown> | undefined; }
|
|
24
|
+
export function loadSnapshot(): Promise<unknown> {
|
|
25
|
+
return globalThis.cmsBuildSnapshot ??= fetchSnapshot();
|
|
26
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
const tiptapNode = z.object({
|
|
4
|
+
type: z.string(),
|
|
5
|
+
text: z.string().optional(),
|
|
6
|
+
attrs: z.record(z.string(), z.unknown()).optional(),
|
|
7
|
+
content: z.array(z.unknown()).optional(),
|
|
8
|
+
}).passthrough();
|
|
9
|
+
|
|
10
|
+
export type TiptapNode = z.infer<typeof tiptapNode>;
|
|
11
|
+
|
|
12
|
+
export function parseTiptapNode(value: unknown): TiptapNode {
|
|
13
|
+
return tiptapNode.parse(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function tiptapChildren(value: TiptapNode): TiptapNode[] {
|
|
17
|
+
return (value.content ?? []).map(parseTiptapNode);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Reject URLs that cannot safely appear in a public document or preview. */
|
|
21
|
+
export function documentUrl(value: unknown): string {
|
|
22
|
+
if (typeof value !== 'string' || (!/^https?:\/\//.test(value) && !value.startsWith('/'))) {
|
|
23
|
+
throw new Error('Unsafe media or link URL');
|
|
24
|
+
}
|
|
25
|
+
if (value.startsWith('/admin/api/media/object/')) {
|
|
26
|
+
throw new Error('Published media requires MEDIA_PUBLIC_URL');
|
|
27
|
+
}
|
|
28
|
+
return value.replace(/["<>]/g, '');
|
|
29
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/* Public Docs palette. Change these six values to match your own brand. */
|
|
2
|
+
:root {
|
|
3
|
+
--sl-color-accent-low: #ede9fe;
|
|
4
|
+
--sl-color-accent: #7c3aed;
|
|
5
|
+
--sl-color-accent-high: #4c1d95;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
:root[data-theme='dark'] {
|
|
9
|
+
--sl-color-accent-low: #2e1065;
|
|
10
|
+
--sl-color-accent: #a78bfa;
|
|
11
|
+
--sl-color-accent-high: #ede9fe;
|
|
12
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { publishedDocuments } from '../src/starlight/schema.ts';
|
|
3
|
+
import { renderDocumentContent } from '../src/starlight/render.ts';
|
|
4
|
+
import { renderPreviewContent, renderPreviewDocument, renderPreviewTocItems } from '../src/starlight/preview-render.ts';
|
|
5
|
+
import { supportedLocales } from '../src/locales.ts';
|
|
6
|
+
|
|
7
|
+
const published = {
|
|
8
|
+
version: 2,
|
|
9
|
+
documents: [{
|
|
10
|
+
id: 'one', title: 'One', slug: 'one', description: '', section: '', order: 1,
|
|
11
|
+
body: { format: 'markdown', value: '# One' }, status: 'published',
|
|
12
|
+
createdAt: '2026-09-09T00:00:00.000Z', updatedAt: '2026-09-09T00:00:00.000Z', publishedAt: '2026-09-09T00:00:00.000Z',
|
|
13
|
+
}],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe('published build boundary', () => {
|
|
17
|
+
it('accepts a versioned published snapshot and rejects duplicate routes', () => {
|
|
18
|
+
expect(publishedDocuments(published).map((doc) => doc.slug)).toEqual(['one']);
|
|
19
|
+
expect(() => publishedDocuments({ ...published, documents: [...published.documents, { ...published.documents[0], id: 'two' }] })).toThrow('Duplicate');
|
|
20
|
+
const alternateLocale = supportedLocales.find((locale) => locale !== 'en');
|
|
21
|
+
expect(publishedDocuments({ ...published, version: 3, documents: alternateLocale ? [...published.documents, { ...published.documents[0], locale: alternateLocale }] : published.documents })).toHaveLength(alternateLocale ? 2 : 1);
|
|
22
|
+
});
|
|
23
|
+
it.each(['../secret', '/absolute', 'admin', 'admin/users', 'a//b', 'a?b'])('rejects reserved or invalid slug %s', (slug) => {
|
|
24
|
+
expect(() => publishedDocuments({ ...published, documents: [{ ...published.documents[0], slug }] })).toThrow();
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('Tiptap renderer', () => {
|
|
29
|
+
it('renders basic text, links, media and Docs blocks without executing content', () => {
|
|
30
|
+
const markdown = renderDocumentContent({ type: 'doc', content: [
|
|
31
|
+
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Guide', marks: [{ type: 'bold' }] }] },
|
|
32
|
+
{ type: 'paragraph', content: [{ type: 'text', text: 'Read ', marks: [] }, { type: 'text', text: 'this', marks: [{ type: 'link', attrs: { href: '/guide' } }] }] },
|
|
33
|
+
{ type: 'image', attrs: { src: '/media/image.png', alt: 'Image' } },
|
|
34
|
+
{ type: 'callout', attrs: { title: 'Note' }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Safe.' }] }] },
|
|
35
|
+
] });
|
|
36
|
+
expect(markdown).toContain('## **Guide**');
|
|
37
|
+
expect(markdown).toContain('[this](/guide)');
|
|
38
|
+
expect(markdown).toContain('');
|
|
39
|
+
expect(markdown).toContain(':::note[Note]');
|
|
40
|
+
});
|
|
41
|
+
it('rejects unknown nodes and unsafe URLs instead of silently dropping them', () => {
|
|
42
|
+
expect(() => renderDocumentContent({ type: 'doc', content: [{ type: 'script' }] })).toThrow('Unsupported');
|
|
43
|
+
expect(() => renderDocumentContent({ type: 'image', attrs: { src: 'javascript:alert(1)' } })).toThrow('Unsafe');
|
|
44
|
+
expect(() => renderDocumentContent({ type: 'image', attrs: { src: '/admin/api/media/object/media/example.png' } })).toThrow('MEDIA_PUBLIC_URL');
|
|
45
|
+
});
|
|
46
|
+
it('renders task lists and rules created by the Simple Editor toolbar', () => {
|
|
47
|
+
const markdown = renderDocumentContent({ type: 'doc', content: [
|
|
48
|
+
{ type: 'taskList', content: [{ type: 'taskItem', attrs: { checked: true }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Install the CMS' }] }] }] },
|
|
49
|
+
{ type: 'horizontalRule' },
|
|
50
|
+
] });
|
|
51
|
+
expect(markdown).toContain('- [x] Install the CMS');
|
|
52
|
+
expect(markdown).toContain('---');
|
|
53
|
+
});
|
|
54
|
+
it('uses the supported CMS node set inside the generated Starlight shell', () => {
|
|
55
|
+
const html = renderPreviewContent({ type: 'doc', content: [
|
|
56
|
+
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Draft <guide>' }] },
|
|
57
|
+
{ type: 'paragraph', content: [{ type: 'text', text: 'Read ', marks: [] }, { type: 'text', text: 'this', marks: [{ type: 'link', attrs: { href: '/guide' } }] }] },
|
|
58
|
+
{ type: 'callout', attrs: { title: 'Note' }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Safe.' }] }] },
|
|
59
|
+
] });
|
|
60
|
+
expect(html).toContain('<h2 id="draft-guide">Draft <guide></h2>');
|
|
61
|
+
expect(html).toContain('<a href="/guide">this</a>');
|
|
62
|
+
expect(html).toContain('starlight-aside--note');
|
|
63
|
+
expect(() => renderPreviewContent({ type: 'doc', content: [{ type: 'script' }] })).toThrow('Unsupported');
|
|
64
|
+
expect(() => renderPreviewContent({ type: 'doc', content: [{ type: 'image', attrs: { src: 'javascript:alert(1)' } }] })).toThrow('Unsafe');
|
|
65
|
+
});
|
|
66
|
+
it('derives stable heading anchors and nested TOC entries from the same saved document', () => {
|
|
67
|
+
const preview = renderPreviewDocument({ type: 'doc', content: [
|
|
68
|
+
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Getting Started' }] },
|
|
69
|
+
{ type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: 'Install' }] },
|
|
70
|
+
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Getting Started' }] },
|
|
71
|
+
{ type: 'heading', attrs: { level: 4 }, content: [{ type: 'text', text: 'Ignored' }] },
|
|
72
|
+
] });
|
|
73
|
+
expect(preview.html).toContain('<h2 id="getting-started">Getting Started</h2>');
|
|
74
|
+
expect(preview.html).toContain('<h2 id="getting-started-1">Getting Started</h2>');
|
|
75
|
+
expect(preview.headings.map(({ id }) => id)).toEqual(['getting-started', 'install', 'getting-started-1', 'ignored']);
|
|
76
|
+
const toc = renderPreviewTocItems(preview.headings, 2, 3, 'toc');
|
|
77
|
+
expect(toc).toContain('href="#getting-started"');
|
|
78
|
+
expect(toc).toContain('href="#install"');
|
|
79
|
+
expect(toc).toContain('style="--depth: 1;"');
|
|
80
|
+
expect(toc).not.toContain('ignored');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { expect, it, vi } from 'vitest';
|
|
2
|
+
import { triggerDeployHook } from '../src/publish/deploy-hook.ts';
|
|
3
|
+
|
|
4
|
+
const url = 'https://api.cloudflare.com/client/v4/workers/builds/deploy_hooks/test-hook';
|
|
5
|
+
it('POSTs a hook using Cloudflare’s documented request shape', async () => {
|
|
6
|
+
const request = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 202 }));
|
|
7
|
+
await triggerDeployHook(url, request);
|
|
8
|
+
expect(request).toHaveBeenCalledWith(url, { method: 'POST' });
|
|
9
|
+
});
|
|
10
|
+
it('returns the build identifier when the hook accepts the request', async () => {
|
|
11
|
+
const request = vi.fn<typeof fetch>().mockResolvedValue(Response.json({ result: { build_uuid: 'build-123', already_exists: true } }, { status: 202 }));
|
|
12
|
+
await expect(triggerDeployHook(url, request)).resolves.toEqual({ buildId: 'build-123', alreadyExists: true });
|
|
13
|
+
});
|
|
14
|
+
it('reports rejected hooks', async () => {
|
|
15
|
+
const request = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 429 }));
|
|
16
|
+
await expect(triggerDeployHook(url, request)).rejects.toThrow('429');
|
|
17
|
+
});
|
|
18
|
+
it('does not expose secret URLs from network errors', async () => {
|
|
19
|
+
const request = vi.fn<typeof fetch>().mockRejectedValue(new Error(`failed: ${url}`));
|
|
20
|
+
await expect(triggerDeployHook(url, request)).rejects.toThrow(/^Workers Deploy Hook transport failed$/);
|
|
21
|
+
});
|
|
22
|
+
it('reports a safe network category for TypeError fetch failures', async () => {
|
|
23
|
+
const request = vi.fn<typeof fetch>().mockRejectedValue(new TypeError(`failed: ${url}`));
|
|
24
|
+
await expect(triggerDeployHook(url, request)).rejects.toThrow(/^Workers Deploy Hook network request failed$/);
|
|
25
|
+
});
|
|
26
|
+
it.each(['http://api.cloudflare.com/client/v4/workers/builds/deploy_hooks/test',
|
|
27
|
+
'https://evil.example/hook', `${url}?secret=value`, 'https://api.cloudflare.com/client/v4/pages/hook'])
|
|
28
|
+
('rejects wrong destinations without a request: %s', async (invalid) => {
|
|
29
|
+
const request = vi.fn<typeof fetch>();
|
|
30
|
+
await expect(triggerDeployHook(invalid, request)).rejects.toThrow();
|
|
31
|
+
expect(request).not.toHaveBeenCalled();
|
|
32
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { contentReferencesMediaObject, signatureMatches } from '../src/media/service.ts';
|
|
3
|
+
|
|
4
|
+
describe('media signatures', () => {
|
|
5
|
+
it.each([
|
|
6
|
+
['image/png', [0x89, 0x50, 0x4e, 0x47]],
|
|
7
|
+
['image/jpeg', [0xff, 0xd8, 0xff]],
|
|
8
|
+
['image/webp', [0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50]],
|
|
9
|
+
['image/avif', [0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70]],
|
|
10
|
+
['video/mp4', [0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70]],
|
|
11
|
+
['video/webm', [0x1a, 0x45, 0xdf, 0xa3]],
|
|
12
|
+
])('accepts a valid %s header', (type, bytes) => {
|
|
13
|
+
expect(signatureMatches(type, new Uint8Array(bytes))).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
it('rejects mismatched bytes', () => {
|
|
16
|
+
expect(signatureMatches('video/mp4', new Uint8Array([0x89, 0x50, 0x4e, 0x47]))).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('media references', () => {
|
|
21
|
+
const key = 'media/example.png';
|
|
22
|
+
|
|
23
|
+
it('finds image and video references in Tiptap JSON', () => {
|
|
24
|
+
expect(contentReferencesMediaObject(JSON.stringify({ type: 'doc', content: [
|
|
25
|
+
{ type: 'image', attrs: { src: 'https://media.example/media/example.png' } },
|
|
26
|
+
{ type: 'video', attrs: { src: '/admin/api/media/object/media/example.png' } },
|
|
27
|
+
] }), key)).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('does not treat plain text as a media reference', () => {
|
|
31
|
+
expect(contentReferencesMediaObject(JSON.stringify({ type: 'doc', content: [
|
|
32
|
+
{ type: 'paragraph', content: [{ type: 'text', text: 'media/example.png' }] },
|
|
33
|
+
] }), key)).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('retains media when historic content is invalid', () => {
|
|
37
|
+
expect(contentReferencesMediaObject('{broken', key)).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { afterAll, beforeAll, expect, it } from 'vitest';
|
|
2
|
+
import { build } from 'esbuild';
|
|
3
|
+
import { Miniflare, convertV4MiniflareOptions } from 'miniflare';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { readFile, mkdtemp, rm, access } from 'node:fs/promises';
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import { publishedDocuments } from '../src/starlight/schema.ts';
|
|
11
|
+
import { cmsSidebar } from '../src/starlight/sidebar.ts';
|
|
12
|
+
import { supportedLocales } from '../src/locales.ts';
|
|
13
|
+
|
|
14
|
+
let mf: Miniflare;
|
|
15
|
+
let output: string;
|
|
16
|
+
const previewShell = `<!doctype html><html><head><title>Preview</title><meta name="description" content=""><meta property="og:title" content="Preview"><link rel="canonical" href="https://example.test/cms-preview-shell/"></head><body><starlight-lang-select>Language</starlight-lang-select><h1 id="_top">Preview</h1><mobile-starlight-toc data-min-h="2" data-max-h="3"><ul class="isMobile toc"><li>Overview</li></ul></mobile-starlight-toc><starlight-toc data-min-h="2" data-max-h="3"><ul class="toc"><li>Overview</li></ul></starlight-toc><div class="sl-markdown-content"><div id="cms-preview-content"></div></div></body></html>`;
|
|
17
|
+
beforeAll(async () => {
|
|
18
|
+
output = await mkdtemp(join(tmpdir(), 'cms-publish-test-'));
|
|
19
|
+
const bundle = await build({ entryPoints: ['src/index.ts'], loader: { '.sql': 'text' }, bundle: true, write: false, format: 'esm', platform: 'browser', target: 'es2022' });
|
|
20
|
+
mf = new Miniflare(convertV4MiniflareOptions({ workers: [{ name: 'cms-test', modules: true, script: bundle.outputFiles[0]!.text, compatibilityDate: '2026-09-09', compatibilityFlags: ['nodejs_compat'], d1Databases: ['DB'], r2Buckets: ['MEDIA'], serviceBindings: { ASSETS: () => new Response(previewShell, { headers: { 'Content-Type': 'text/html; charset=utf-8' } }) } }] }));
|
|
21
|
+
const db = await mf.getD1Database('DB');
|
|
22
|
+
// An incompatible pre-existing table must fail closed, without recording
|
|
23
|
+
// a completed migration. Repairing this disposable test DB permits retry.
|
|
24
|
+
await db.prepare('CREATE TABLE folder (id TEXT PRIMARY KEY)').run();
|
|
25
|
+
expect((await mf.dispatchFetch('http://localhost/admin/export/snapshot')).status).toBe(503);
|
|
26
|
+
expect((await db.prepare('SELECT name FROM d1_migrations').all()).results).toHaveLength(0);
|
|
27
|
+
await db.prepare('DROP TABLE folder').run();
|
|
28
|
+
const responses = await Promise.all(Array.from({ length: 4 }, () => mf.dispatchFetch('http://localhost/admin/export/snapshot')));
|
|
29
|
+
for (const response of responses) expect(response.status).toBe(200);
|
|
30
|
+
expect((await db.prepare('SELECT name FROM d1_migrations').all()).results).toHaveLength(2);
|
|
31
|
+
}, 30000);
|
|
32
|
+
afterAll(async () => { await mf?.dispose(); if (output) await rm(output, { recursive: true, force: true }); });
|
|
33
|
+
|
|
34
|
+
async function request(path: string, method = 'GET', body?: unknown) {
|
|
35
|
+
const response = await mf.dispatchFetch(`http://localhost/admin/${path}`, {
|
|
36
|
+
method, headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'cloudflare-starlight-cms' },
|
|
37
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
38
|
+
});
|
|
39
|
+
if (!response.ok) throw new Error(`${method} ${path}: ${response.status} ${await response.text()}`);
|
|
40
|
+
return response.status === 204 ? null : response.json();
|
|
41
|
+
}
|
|
42
|
+
async function buildDocs() {
|
|
43
|
+
const endpoint = new URL('/admin/export/snapshot', await mf.ready).href;
|
|
44
|
+
await promisify(execFile)(process.execPath, ['node_modules/astro/bin/astro.mjs', 'build', '--outDir', output], {
|
|
45
|
+
env: { ...process.env, CMS_EXPORT_URL: endpoint }, maxBuffer: 4 * 1024 * 1024,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const identity = z.object({ id: z.string(), version: z.number() });
|
|
49
|
+
const hasJapanese = (supportedLocales as readonly string[]).includes('ja');
|
|
50
|
+
|
|
51
|
+
it('redirects the bare admin path to the Access-protected admin path', async () => {
|
|
52
|
+
const response = await mf.dispatchFetch('http://localhost/admin', { redirect: 'manual' });
|
|
53
|
+
expect(response.status).toBe(302);
|
|
54
|
+
expect(response.headers.get('location')).toBe('/admin/');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('serves a saved Draft in the generated Starlight preview shell without requesting a build', async () => {
|
|
58
|
+
const page = identity.parse(await request('api/documents', 'POST', {
|
|
59
|
+
title: 'Draft <title>', slug: 'preview-document', folderId: null, order: 0, description: 'Draft description',
|
|
60
|
+
contentJson: { type: 'doc', content: [
|
|
61
|
+
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Draft section' }] },
|
|
62
|
+
{ type: 'paragraph', content: [{ type: 'text', text: 'Draft <body>' }] },
|
|
63
|
+
] },
|
|
64
|
+
}));
|
|
65
|
+
const response = await mf.dispatchFetch(`http://localhost/admin/preview/${page.id}?locale=en`);
|
|
66
|
+
expect(response.status).toBe(200);
|
|
67
|
+
expect(response.headers.get('cache-control')).toBe('private, no-store');
|
|
68
|
+
expect(response.headers.get('x-robots-tag')).toBe('noindex, nofollow');
|
|
69
|
+
const html = await response.text();
|
|
70
|
+
expect(html).toContain('<title>Draft <title> | Preview</title>');
|
|
71
|
+
expect(html).toContain('<h1 id="_top">Draft <title></h1>');
|
|
72
|
+
expect(html).toContain('<h2 id="draft-section">Draft section</h2>');
|
|
73
|
+
expect(html).toContain('<p>Draft <body></p>');
|
|
74
|
+
expect(html).toContain('<div id="cms-preview-content"><h2 id="draft-section">Draft section</h2><p>Draft <body></p></div>');
|
|
75
|
+
expect(html).toContain('href="#draft-section"');
|
|
76
|
+
expect(html).not.toContain('Overview');
|
|
77
|
+
expect(html).not.toContain('canonical');
|
|
78
|
+
expect(html).not.toContain('starlight-lang-select');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('keeps drafts private and exports folder labels/order; records public moves and deletion', async () => {
|
|
82
|
+
const folder = z.object({ id: z.string() }).parse(await request('api/folders', 'POST', { name: 'Getting Started', slug: 'guides', order: 3 }));
|
|
83
|
+
const input = { title: 'Install', slug: 'install', folderId: folder.id, order: 0, description: '', contentJson: { type: 'doc', content: [
|
|
84
|
+
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Install steps' }] },
|
|
85
|
+
{ type: 'paragraph', content: [{ type: 'text', text: 'Public text' }] },
|
|
86
|
+
] } };
|
|
87
|
+
let page = identity.parse(await request('api/documents', 'POST', input));
|
|
88
|
+
expect(publishedDocuments(await request('export/snapshot'))).toEqual([]);
|
|
89
|
+
const result = z.object({ document: identity }).parse(await request(`api/documents/${page.id}/publish`, 'POST', { version: page.version }));
|
|
90
|
+
page = result.document;
|
|
91
|
+
const published = await request('export/snapshot');
|
|
92
|
+
expect(cmsSidebar(published)[0]?.label).toBe('Getting Started');
|
|
93
|
+
await buildDocs();
|
|
94
|
+
await access(join(output, 'cms-preview-shell/index.html'));
|
|
95
|
+
if (hasJapanese) await access(join(output, 'ja/cms-preview-shell/index.html'));
|
|
96
|
+
const publicPage = await readFile(join(output, 'guides/install/index.html'), 'utf8');
|
|
97
|
+
expect(publicPage).toContain('Getting Started');
|
|
98
|
+
expect(publicPage).toContain('id="install-steps"');
|
|
99
|
+
if (hasJapanese) expect(await readFile(join(output, 'ja/guides/install/index.html'), 'utf8')).toContain('Public text');
|
|
100
|
+
await access(join(output, 'pagefind/pagefind.js'));
|
|
101
|
+
page = identity.parse(await request(`api/documents/${page.id}`, 'PUT', { ...input, title: 'Draft title', version: page.version }));
|
|
102
|
+
expect(await request('export/snapshot')).toEqual(published);
|
|
103
|
+
// A rejected save must not rename the published URL either.
|
|
104
|
+
const conflict = await mf.dispatchFetch(`http://localhost/admin/api/documents/${page.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'cloudflare-starlight-cms' }, body: JSON.stringify({ ...input, slug: 'wrong', version: page.version - 1 }) });
|
|
105
|
+
expect(conflict.status).toBe(409);
|
|
106
|
+
expect(await request('export/snapshot')).toEqual(published);
|
|
107
|
+
await request('api/tree/children', 'PUT', { parentId: null, childIds: [`document:${page.id}`] });
|
|
108
|
+
expect(publishedDocuments(await request('export/snapshot'))[0]?.slug).toBe('install');
|
|
109
|
+
await buildDocs();
|
|
110
|
+
await access(join(output, 'install/index.html'));
|
|
111
|
+
await expect(access(join(output, 'guides/install/index.html'))).rejects.toThrow();
|
|
112
|
+
await request(`api/documents/${page.id}`, 'DELETE', { version: page.version });
|
|
113
|
+
expect(publishedDocuments(await request('export/snapshot'))).toEqual([]);
|
|
114
|
+
const db = await mf.getD1Database('DB');
|
|
115
|
+
const changes = await db.prepare("SELECT count(*) AS count FROM publish_delivery WHERE trigger_kind='site'").first<{ count: number }>();
|
|
116
|
+
expect(changes?.count).toBe(2);
|
|
117
|
+
await buildDocs();
|
|
118
|
+
await expect(access(join(output, 'install/index.html'))).rejects.toThrow();
|
|
119
|
+
}, 30000);
|
|
120
|
+
|
|
121
|
+
it('recovers an interrupted delivery but rejects a retry while a sender holds the lease', async () => {
|
|
122
|
+
const db = await mf.getD1Database('DB');
|
|
123
|
+
await db.batch([
|
|
124
|
+
db.prepare("INSERT INTO publish_delivery (id,trigger_kind,status,attempts,requested_at,next_retry_at) VALUES ('stale','site','pending',1,?,?)").bind(Date.now()-60000, Date.now()-1000),
|
|
125
|
+
db.prepare("INSERT INTO publish_delivery (id,trigger_kind,status,attempts,requested_at,next_retry_at) VALUES ('active','site','pending',1,?,?)").bind(Date.now(), Date.now()+30000),
|
|
126
|
+
]);
|
|
127
|
+
const response = z.object({ delivery: z.object({ status: z.string(), attempts: z.number() }) }).parse(await request('api/publish/deliveries/stale/retry', 'POST'));
|
|
128
|
+
expect(response.delivery).toMatchObject({ status: 'skipped', attempts: 2 });
|
|
129
|
+
const active = await mf.dispatchFetch('http://localhost/admin/api/publish/deliveries/active/retry', { method: 'POST', headers: { 'X-Requested-With': 'cloudflare-starlight-cms' } });
|
|
130
|
+
expect(active.status).toBe(409);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('deletes only media that is absent from drafts, published pages, and revisions', async () => {
|
|
134
|
+
const db = await mf.getD1Database('DB');
|
|
135
|
+
const now = Date.now();
|
|
136
|
+
const unused = { id: '00000000-0000-4000-8000-000000000001', objectKey: 'media/00000000-0000-4000-8000-000000000001.png' };
|
|
137
|
+
await db.prepare('INSERT INTO media (id, object_key, file_name, content_type, size, created_at) VALUES (?, ?, ?, ?, ?, ?)')
|
|
138
|
+
.bind(unused.id, unused.objectKey, 'unused.png', 'image/png', 4, now).run();
|
|
139
|
+
const deleted = await mf.dispatchFetch(`http://localhost/admin/api/media/${unused.id}`, {
|
|
140
|
+
method: 'DELETE', headers: { 'X-Requested-With': 'cloudflare-starlight-cms' },
|
|
141
|
+
});
|
|
142
|
+
expect(deleted.status).toBe(204);
|
|
143
|
+
|
|
144
|
+
const used = { id: '00000000-0000-4000-8000-000000000002', objectKey: 'media/00000000-0000-4000-8000-000000000002.png' };
|
|
145
|
+
await db.prepare('INSERT INTO media (id, object_key, file_name, content_type, size, created_at) VALUES (?, ?, ?, ?, ?, ?)')
|
|
146
|
+
.bind(used.id, used.objectKey, 'used.png', 'image/png', 4, now).run();
|
|
147
|
+
await request('api/documents', 'POST', {
|
|
148
|
+
title: 'Media reference', slug: 'media-reference', folderId: null, order: 0, description: '',
|
|
149
|
+
contentJson: { type: 'doc', content: [{ type: 'image', attrs: { src: `/admin/api/media/object/${used.objectKey}` } }] },
|
|
150
|
+
});
|
|
151
|
+
const rejected = await mf.dispatchFetch(`http://localhost/admin/api/media/${used.id}`, {
|
|
152
|
+
method: 'DELETE', headers: { 'X-Requested-With': 'cloudflare-starlight-cms' },
|
|
153
|
+
});
|
|
154
|
+
expect(rejected.status).toBe(409);
|
|
155
|
+
expect(await rejected.json()).toMatchObject({ error: expect.stringContaining('still used') });
|
|
156
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"jsx": "react-jsx",
|
|
5
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
6
|
+
"baseUrl": ".",
|
|
7
|
+
"paths": {
|
|
8
|
+
"@/*": ["src/admin/*"]
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"include": [
|
|
12
|
+
"src/admin/**/*.ts",
|
|
13
|
+
"src/admin/**/*.tsx",
|
|
14
|
+
"src/scss.d.ts",
|
|
15
|
+
"worker-configuration.d.ts"
|
|
16
|
+
],
|
|
17
|
+
"exclude": []
|
|
18
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"noUncheckedIndexedAccess": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"allowImportingTsExtensions": true,
|
|
11
|
+
"types": [
|
|
12
|
+
"node"
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
"include": [
|
|
16
|
+
"src/**/*.ts",
|
|
17
|
+
"tests/**/*.ts",
|
|
18
|
+
"worker-configuration.d.ts"
|
|
19
|
+
],
|
|
20
|
+
"exclude": [
|
|
21
|
+
"src/admin/**"
|
|
22
|
+
]
|
|
23
|
+
}
|