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,171 @@
|
|
|
1
|
+
import { folderDeliveryStatement, siteDeliveryStatement } from '../publish/record.ts';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { slug } from '../documents/validation.ts';
|
|
4
|
+
import type { RuntimeEnv } from '../env.ts';
|
|
5
|
+
import { defaultLocale, isSupportedLocale, type SupportedLocale } from '../locales.ts';
|
|
6
|
+
|
|
7
|
+
const folderInput = z.object({ name: z.string().trim().min(1).max(120), slug, parentId: z.string().uuid().nullable().default(null), order: z.number().int().min(0).max(100000).default(0) });
|
|
8
|
+
export class FolderNotFoundError extends Error {}
|
|
9
|
+
export class NavigationConflictError extends Error {}
|
|
10
|
+
export type PublicationState = 'draft' | 'changes' | 'published';
|
|
11
|
+
export type TreeItem = {
|
|
12
|
+
id: string; parentId: string | null; kind: 'folder' | 'document'; name: string; slug: string; order: number;
|
|
13
|
+
hasTranslation: boolean; translationLocales: SupportedLocale[]; translationStates: { locale: SupportedLocale; state: PublicationState }[];
|
|
14
|
+
documentId?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
async function assertAvailable(env: RuntimeEnv, parentId: string | null, slugValue: string, ignoreFolder = '', ignoreDocument = '') {
|
|
18
|
+
const [folderMatch, documentMatch] = await Promise.all([
|
|
19
|
+
env.DB.prepare('SELECT id FROM folder WHERE parent_id IS ? AND slug=? AND id<>? LIMIT 1').bind(parentId, slugValue, ignoreFolder).first(),
|
|
20
|
+
env.DB.prepare('SELECT id FROM document WHERE folder_id IS ? AND slug=? AND id<>? LIMIT 1').bind(parentId, slugValue, ignoreDocument).first(),
|
|
21
|
+
]);
|
|
22
|
+
if (folderMatch || documentMatch) throw new NavigationConflictError('A folder or page already uses this URL segment');
|
|
23
|
+
}
|
|
24
|
+
async function assertParentFolder(env: RuntimeEnv, parentId: string | null) {
|
|
25
|
+
if (!parentId) return;
|
|
26
|
+
if (!await env.DB.prepare('SELECT id FROM folder WHERE id=?').bind(parentId).first()) throw new NavigationConflictError('The destination folder no longer exists');
|
|
27
|
+
}
|
|
28
|
+
async function assertNoFolderCycle(env: RuntimeEnv, folderId: string, parentId: string | null) {
|
|
29
|
+
let cursor = parentId;
|
|
30
|
+
const seen = new Set<string>();
|
|
31
|
+
while (cursor) {
|
|
32
|
+
if (cursor === folderId || seen.has(cursor)) throw new NavigationConflictError('A folder cannot be moved into itself or one of its descendants');
|
|
33
|
+
seen.add(cursor);
|
|
34
|
+
const parent = await env.DB.prepare('SELECT parent_id FROM folder WHERE id=?').bind(cursor).first<{ parent_id: string | null }>();
|
|
35
|
+
if (!parent) throw new NavigationConflictError('The destination folder no longer exists');
|
|
36
|
+
cursor = parent.parent_id;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function listTree(env: RuntimeEnv, locale: SupportedLocale = defaultLocale): Promise<TreeItem[]> {
|
|
41
|
+
const [folders, documents, states] = await Promise.all([
|
|
42
|
+
env.DB.prepare("SELECT f.id,f.parent_id,COALESCE(t.name,fallback.name,f.slug) AS name,f.slug,f.sort_order,t.folder_id IS NOT NULL AS has_translation,(SELECT group_concat(locale, ',') FROM folder_translation WHERE folder_id=f.id) AS translation_locales FROM folder f LEFT JOIN folder_translation t ON t.folder_id=f.id AND t.locale=? LEFT JOIN folder_translation fallback ON fallback.folder_id=f.id AND fallback.locale=? WHERE EXISTS (SELECT 1 FROM folder_translation WHERE folder_id=f.id) ORDER BY f.sort_order,f.slug").bind(locale, defaultLocale).all<{ id:string; parent_id:string|null; name:string; slug:string; sort_order:number; has_translation:number; translation_locales:string|null }>(),
|
|
43
|
+
env.DB.prepare("SELECT d.id,d.folder_id,COALESCE(t.title,fallback.title,d.slug) AS title,d.slug,d.sort_order,t.document_id IS NOT NULL AS has_translation,(SELECT group_concat(locale, ',') FROM document_translation WHERE document_id=d.id) AS translation_locales FROM document d LEFT JOIN document_translation t ON t.document_id=d.id AND t.locale=? LEFT JOIN document_translation fallback ON fallback.document_id=d.id AND fallback.locale=? WHERE EXISTS (SELECT 1 FROM document_translation WHERE document_id=d.id) ORDER BY d.sort_order,d.slug").bind(locale, defaultLocale).all<{ id:string; folder_id:string|null; title:string; slug:string; sort_order:number; has_translation:number; translation_locales:string|null }>(),
|
|
44
|
+
env.DB.prepare(`
|
|
45
|
+
SELECT t.document_id,t.locale,
|
|
46
|
+
CASE
|
|
47
|
+
WHEN t.published_revision_id IS NULL THEN 'draft'
|
|
48
|
+
WHEN r.title <> t.title OR r.description <> t.description OR r.content_json <> t.content_json THEN 'changes'
|
|
49
|
+
ELSE 'published'
|
|
50
|
+
END AS state
|
|
51
|
+
FROM document_translation t
|
|
52
|
+
LEFT JOIN document_revision r ON r.id=t.published_revision_id`).all<{ document_id: string; locale: SupportedLocale; state: PublicationState }>(),
|
|
53
|
+
]);
|
|
54
|
+
const locales = (value: string | null) => value?.split(',').filter(isSupportedLocale) ?? [];
|
|
55
|
+
const statesByDocument = new Map<string, { locale: SupportedLocale; state: PublicationState }[]>();
|
|
56
|
+
for (const state of states.results) {
|
|
57
|
+
const values = statesByDocument.get(state.document_id) ?? [];
|
|
58
|
+
values.push({ locale: state.locale, state: state.state });
|
|
59
|
+
statesByDocument.set(state.document_id, values);
|
|
60
|
+
}
|
|
61
|
+
return [
|
|
62
|
+
...folders.results.map((row) => ({ id: `folder:${row.id}`, parentId: row.parent_id ? `folder:${row.parent_id}` : null, kind: 'folder' as const, name: row.name, slug: row.slug, order: row.sort_order, hasTranslation: Boolean(row.has_translation), translationLocales: locales(row.translation_locales), translationStates: [] })),
|
|
63
|
+
...documents.results.map((row) => ({ id: `document:${row.id}`, parentId: row.folder_id ? `folder:${row.folder_id}` : null, kind: 'document' as const, name: row.title, slug: row.slug, order: row.sort_order, hasTranslation: Boolean(row.has_translation), translationLocales: locales(row.translation_locales), translationStates: statesByDocument.get(row.id) ?? [], documentId: row.id })),
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function createFolder(env: RuntimeEnv, raw: unknown, locale: SupportedLocale = defaultLocale) {
|
|
68
|
+
if (locale !== defaultLocale) throw new NavigationConflictError(`Create folders in ${defaultLocale}, then add a translation`);
|
|
69
|
+
const input = folderInput.parse(raw); await assertParentFolder(env, input.parentId); await assertAvailable(env, input.parentId, input.slug);
|
|
70
|
+
const id = crypto.randomUUID(); const now = Date.now();
|
|
71
|
+
await env.DB.batch([
|
|
72
|
+
env.DB.prepare('INSERT INTO folder (id,parent_id,slug,sort_order,created_at,updated_at) VALUES (?,?,?,?,?,?)').bind(id, input.parentId, input.slug, input.order, now, now),
|
|
73
|
+
env.DB.prepare('INSERT INTO folder_translation (folder_id,locale,name,created_at,updated_at) VALUES (?,?,?,?,?)').bind(id, locale, input.name, now, now),
|
|
74
|
+
]);
|
|
75
|
+
return { id, ...input, createdAt: now, updatedAt: now };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function updateFolder(env: RuntimeEnv, id: string, raw: unknown, locale: SupportedLocale = defaultLocale) {
|
|
79
|
+
const input = folderInput.parse(raw);
|
|
80
|
+
if (!await env.DB.prepare('SELECT id FROM folder WHERE id=?').bind(id).first()) throw new FolderNotFoundError();
|
|
81
|
+
await assertNoFolderCycle(env, id, input.parentId); await assertAvailable(env, input.parentId, input.slug, id);
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
const results = await env.DB.batch([
|
|
84
|
+
env.DB.prepare('UPDATE folder SET parent_id=?,slug=?,sort_order=?,updated_at=? WHERE id=?').bind(input.parentId, input.slug, input.order, now, id),
|
|
85
|
+
env.DB.prepare('UPDATE folder_translation SET name=?,updated_at=? WHERE folder_id=? AND locale=?').bind(input.name, now, id, locale),
|
|
86
|
+
folderDeliveryStatement(env, id),
|
|
87
|
+
]);
|
|
88
|
+
if (results[0]?.meta.changes !== 1 || results[1]?.meta.changes !== 1) throw new FolderNotFoundError();
|
|
89
|
+
return { id, ...input, updatedAt: now };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function createFolderTranslation(env: RuntimeEnv, folderId: string, locale: SupportedLocale, sourceLocale: SupportedLocale = defaultLocale) {
|
|
93
|
+
if (locale === sourceLocale) throw new NavigationConflictError('Choose a different language');
|
|
94
|
+
const source = await env.DB.prepare('SELECT name FROM folder_translation WHERE folder_id=? AND locale=?').bind(folderId, sourceLocale).first<{ name: string }>();
|
|
95
|
+
if (!source) throw new FolderNotFoundError();
|
|
96
|
+
const existing = await env.DB.prepare('SELECT folder_id FROM folder_translation WHERE folder_id=? AND locale=?').bind(folderId, locale).first();
|
|
97
|
+
if (existing) throw new NavigationConflictError('This translation already exists');
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
await env.DB.batch([
|
|
100
|
+
env.DB.prepare('INSERT INTO folder_translation (folder_id,locale,name,created_at,updated_at) VALUES (?,?,?,?,?)').bind(folderId, locale, source.name, now, now),
|
|
101
|
+
folderDeliveryStatement(env, folderId),
|
|
102
|
+
]);
|
|
103
|
+
return { id: folderId, name: source.name, updatedAt: now };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function deleteFolder(env: RuntimeEnv, id: string) {
|
|
107
|
+
const [childFolder, childDocument] = await Promise.all([
|
|
108
|
+
env.DB.prepare('SELECT id FROM folder WHERE parent_id=? LIMIT 1').bind(id).first(),
|
|
109
|
+
env.DB.prepare('SELECT id FROM document WHERE folder_id=? LIMIT 1').bind(id).first(),
|
|
110
|
+
]);
|
|
111
|
+
if (childFolder || childDocument) throw new NavigationConflictError('Move or delete the folder contents before deleting this folder');
|
|
112
|
+
const deleted = await env.DB.prepare('DELETE FROM folder WHERE id=? RETURNING id').bind(id).first<{ id: string }>();
|
|
113
|
+
if (!deleted) throw new FolderNotFoundError();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const treeChildId = z.string().regex(/^(folder|document):[0-9a-f-]{36}$/i);
|
|
117
|
+
const childrenInput = z.object({ parentId: z.string().uuid().nullable(), childIds: z.array(treeChildId).max(10_000) });
|
|
118
|
+
type NavigationRow = { id: string; kind: 'folder' | 'document'; slug: string; order: number };
|
|
119
|
+
|
|
120
|
+
async function siblings(env: RuntimeEnv, parentId: string | null, excluded: NavigationRow | null = null): Promise<NavigationRow[]> {
|
|
121
|
+
const [folders, documents] = await Promise.all([
|
|
122
|
+
env.DB.prepare('SELECT id,slug,sort_order FROM folder WHERE parent_id IS ?').bind(parentId).all<{ id: string; slug: string; sort_order: number }>(),
|
|
123
|
+
env.DB.prepare('SELECT id,slug,sort_order FROM document WHERE folder_id IS ?').bind(parentId).all<{ id: string; slug: string; sort_order: number }>(),
|
|
124
|
+
]);
|
|
125
|
+
return [
|
|
126
|
+
...folders.results.map((row) => ({ id: row.id, kind: 'folder' as const, slug: row.slug, order: row.sort_order })),
|
|
127
|
+
...documents.results.map((row) => ({ id: row.id, kind: 'document' as const, slug: row.slug, order: row.sort_order })),
|
|
128
|
+
].filter((row) => !excluded || row.kind !== excluded.kind || row.id !== excluded.id)
|
|
129
|
+
.sort((a, b) => a.order - b.order || a.slug.localeCompare(b.slug));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function reorderStatements(env: RuntimeEnv, entries: NavigationRow[], parentId: string | null, now: number) {
|
|
133
|
+
return entries.map((entry, order) => env.DB.prepare(entry.kind === 'folder'
|
|
134
|
+
? 'UPDATE folder SET parent_id=?,sort_order=?,updated_at=? WHERE id=?'
|
|
135
|
+
: 'UPDATE document SET folder_id=?,sort_order=?,updated_at=? WHERE id=?')
|
|
136
|
+
.bind(parentId, order, now, entry.id));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function replaceTreeChildren(env: RuntimeEnv, raw: unknown) {
|
|
140
|
+
const input = childrenInput.parse(raw);
|
|
141
|
+
if (new Set(input.childIds).size !== input.childIds.length) throw new NavigationConflictError('A navigation item was included more than once');
|
|
142
|
+
await assertParentFolder(env, input.parentId);
|
|
143
|
+
const entries = await Promise.all(input.childIds.map(async (childId) => {
|
|
144
|
+
const [kind, id] = childId.split(':') as ['folder' | 'document', string];
|
|
145
|
+
const row = await env.DB.prepare(`SELECT slug FROM ${kind} WHERE id=?`).bind(id).first<{ slug: string }>();
|
|
146
|
+
if (!row) throw new FolderNotFoundError();
|
|
147
|
+
if (kind === 'folder') await assertNoFolderCycle(env, id, input.parentId);
|
|
148
|
+
return { id, kind, slug: row.slug, order: 0 } as NavigationRow;
|
|
149
|
+
}));
|
|
150
|
+
|
|
151
|
+
const movedIds = new Set(entries.map((entry) => `${entry.kind}:${entry.id}`));
|
|
152
|
+
const existing = await siblings(env, input.parentId);
|
|
153
|
+
const usedSlugs = new Set(existing.filter((entry) => !movedIds.has(`${entry.kind}:${entry.id}`)).map((entry) => entry.slug));
|
|
154
|
+
for (const entry of entries) {
|
|
155
|
+
if (usedSlugs.has(entry.slug)) throw new NavigationConflictError('A folder or page already uses this URL segment');
|
|
156
|
+
usedSlugs.add(entry.slug);
|
|
157
|
+
}
|
|
158
|
+
if (entries.length > 0) await env.DB.batch([
|
|
159
|
+
...reorderStatements(env, entries, input.parentId, Date.now()),
|
|
160
|
+
siteDeliveryStatement(env, `EXISTS (
|
|
161
|
+
WITH RECURSIVE moved(id) AS (SELECT value FROM json_each(?)),
|
|
162
|
+
descendants(id) AS (
|
|
163
|
+
SELECT id FROM folder WHERE 'folder:' || id IN (SELECT id FROM moved)
|
|
164
|
+
UNION SELECT f.id FROM folder f JOIN descendants p ON f.parent_id=p.id
|
|
165
|
+
) SELECT 1 FROM document d JOIN document_translation t ON t.document_id=d.id
|
|
166
|
+
WHERE t.published_revision_id IS NOT NULL AND
|
|
167
|
+
(d.folder_id IN (SELECT id FROM descendants) OR 'document:' || d.id IN (SELECT id FROM moved))
|
|
168
|
+
)`, [JSON.stringify(input.childIds)]),
|
|
169
|
+
]);
|
|
170
|
+
return { parentId: input.parentId, childIds: input.childIds };
|
|
171
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
import PreviewShell from '../../starlight/preview-shell.astro';
|
|
3
|
+
import { siteConfig } from '../../site.config.ts';
|
|
4
|
+
|
|
5
|
+
export function getStaticPaths() {
|
|
6
|
+
return siteConfig.locales
|
|
7
|
+
.filter(({ code }) => code !== siteConfig.defaultLocale)
|
|
8
|
+
.map(({ code }) => ({ params: { locale: code } }));
|
|
9
|
+
}
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
<PreviewShell />
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type DeployHookResult = { buildId: string | null; alreadyExists: boolean };
|
|
2
|
+
|
|
3
|
+
function transportError(error: unknown): Error {
|
|
4
|
+
// Do not persist runtime error messages: they can contain the secret hook URL.
|
|
5
|
+
if (error instanceof DOMException && error.name === 'TimeoutError') {
|
|
6
|
+
return new Error('Workers Deploy Hook request timed out');
|
|
7
|
+
}
|
|
8
|
+
if (error instanceof TypeError) {
|
|
9
|
+
return new Error('Workers Deploy Hook network request failed');
|
|
10
|
+
}
|
|
11
|
+
return new Error('Workers Deploy Hook transport failed');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Low-level Workers Builds transport. The URL is a secret, so errors deliberately
|
|
16
|
+
* contain no destination or response body.
|
|
17
|
+
*/
|
|
18
|
+
export async function triggerDeployHook(url: string, request: typeof fetch = fetch): Promise<DeployHookResult> {
|
|
19
|
+
const parsed = new URL(url);
|
|
20
|
+
if (parsed.origin !== 'https://api.cloudflare.com' ||
|
|
21
|
+
!/^\/client\/v4\/workers\/builds\/deploy_hooks\/[a-zA-Z0-9_-]+$/.test(parsed.pathname) ||
|
|
22
|
+
parsed.username || parsed.password || parsed.search || parsed.hash) {
|
|
23
|
+
throw new Error('Invalid Workers Deploy Hook URL');
|
|
24
|
+
}
|
|
25
|
+
let response: Response;
|
|
26
|
+
// This deliberately matches Cloudflare's documented Deploy Hook request. The
|
|
27
|
+
// hook URL itself is the credential; no Access or API-token header is used.
|
|
28
|
+
try { response = await request(parsed.href, { method: 'POST' }); }
|
|
29
|
+
catch (error) { throw transportError(error); }
|
|
30
|
+
if (!response.ok) throw new Error(`Workers Deploy Hook rejected: ${response.status}`);
|
|
31
|
+
try {
|
|
32
|
+
const body = await response.json() as { result?: { build_uuid?: unknown; already_exists?: unknown } };
|
|
33
|
+
return {
|
|
34
|
+
buildId: typeof body.result?.build_uuid === 'string' ? body.result.build_uuid : null,
|
|
35
|
+
alreadyExists: body.result?.already_exists === true,
|
|
36
|
+
};
|
|
37
|
+
} catch {
|
|
38
|
+
// Cloudflare currently returns JSON, but accepting a successful empty response
|
|
39
|
+
// keeps the hook boundary compatible with a future 2xx response format.
|
|
40
|
+
return { buildId: null, alreadyExists: false };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { RuntimeEnv } from '../env.ts';
|
|
2
|
+
|
|
3
|
+
/** Append this statement to the mutation's batch so a committed change is recoverable. */
|
|
4
|
+
export function siteDeliveryStatement(env: RuntimeEnv, condition: string, bindings: (string | number | null)[] = []) {
|
|
5
|
+
return env.DB.prepare(`INSERT INTO publish_delivery
|
|
6
|
+
(id,trigger_kind,status,attempts,already_exists,requested_at)
|
|
7
|
+
SELECT ?,'site','pending',0,0,? WHERE ${condition}`)
|
|
8
|
+
.bind(crypto.randomUUID(), Date.now(), ...bindings);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const publishedFolderCondition = `EXISTS (
|
|
12
|
+
WITH RECURSIVE descendants(id) AS (
|
|
13
|
+
SELECT id FROM folder WHERE id=?
|
|
14
|
+
UNION SELECT f.id FROM folder f JOIN descendants p ON f.parent_id=p.id
|
|
15
|
+
) SELECT 1 FROM document d JOIN document_translation t ON t.document_id=d.id
|
|
16
|
+
WHERE d.folder_id IN (SELECT id FROM descendants) AND t.published_revision_id IS NOT NULL
|
|
17
|
+
)`;
|
|
18
|
+
export function folderDeliveryStatement(env: RuntimeEnv, folderId: string) {
|
|
19
|
+
return siteDeliveryStatement(env, publishedFolderCondition, [folderId]);
|
|
20
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { RuntimeEnv } from '../env.ts';
|
|
2
|
+
import { triggerDeployHook } from './deploy-hook.ts';
|
|
3
|
+
import { publishDocument, type DocumentView } from '../documents/service.ts';
|
|
4
|
+
import type { SupportedLocale } from '../locales.ts';
|
|
5
|
+
|
|
6
|
+
export type PublishDeliveryStatus = 'pending' | 'accepted' | 'failed' | 'skipped';
|
|
7
|
+
export type PublishDeliveryKind = 'document' | 'site';
|
|
8
|
+
|
|
9
|
+
type DeliveryRow = {
|
|
10
|
+
id: string;
|
|
11
|
+
trigger_kind: PublishDeliveryKind;
|
|
12
|
+
document_translation_id: string | null;
|
|
13
|
+
status: PublishDeliveryStatus;
|
|
14
|
+
attempts: number;
|
|
15
|
+
build_id: string | null;
|
|
16
|
+
already_exists: number;
|
|
17
|
+
last_error: string | null;
|
|
18
|
+
requested_at: number;
|
|
19
|
+
accepted_at: number | null;
|
|
20
|
+
next_retry_at: number | null;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type PublishDelivery = {
|
|
24
|
+
id: string;
|
|
25
|
+
triggerKind: PublishDeliveryKind;
|
|
26
|
+
documentTranslationId: string | null;
|
|
27
|
+
status: PublishDeliveryStatus;
|
|
28
|
+
attempts: number;
|
|
29
|
+
buildId: string | null;
|
|
30
|
+
alreadyExists: boolean;
|
|
31
|
+
lastError: string | null;
|
|
32
|
+
requestedAt: number;
|
|
33
|
+
acceptedAt: number | null;
|
|
34
|
+
nextRetryAt: number | null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export class PublishDeliveryNotFoundError extends Error {}
|
|
38
|
+
export class PublishDeliveryConflictError extends Error {}
|
|
39
|
+
|
|
40
|
+
function toView(row: DeliveryRow): PublishDelivery {
|
|
41
|
+
return {
|
|
42
|
+
id: row.id, triggerKind: row.trigger_kind, documentTranslationId: row.document_translation_id,
|
|
43
|
+
status: row.status, attempts: row.attempts, buildId: row.build_id,
|
|
44
|
+
alreadyExists: Boolean(row.already_exists), lastError: row.last_error,
|
|
45
|
+
requestedAt: row.requested_at, acceptedAt: row.accepted_at, nextRetryAt: row.next_retry_at,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function getDelivery(env: RuntimeEnv, id: string): Promise<DeliveryRow> {
|
|
50
|
+
const delivery = await env.DB.prepare(`
|
|
51
|
+
SELECT id,trigger_kind,document_translation_id,status,attempts,build_id,already_exists,last_error,
|
|
52
|
+
requested_at,accepted_at,next_retry_at
|
|
53
|
+
FROM publish_delivery WHERE id=?`).bind(id).first<DeliveryRow>();
|
|
54
|
+
if (!delivery) throw new PublishDeliveryNotFoundError();
|
|
55
|
+
return delivery;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function createPublishDelivery(env: RuntimeEnv, triggerKind: PublishDeliveryKind, documentTranslationId: string | null = null) {
|
|
59
|
+
const id = crypto.randomUUID();
|
|
60
|
+
const requestedAt = Date.now();
|
|
61
|
+
await env.DB.prepare(`
|
|
62
|
+
INSERT INTO publish_delivery (id,trigger_kind,document_translation_id,status,attempts,already_exists,requested_at)
|
|
63
|
+
VALUES (?,?,?,'pending',0,0,?)`).bind(id, triggerKind, documentTranslationId, requestedAt).run();
|
|
64
|
+
return getDelivery(env, id);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Send one already-persisted delivery. A request is never sent before its row exists. */
|
|
68
|
+
export async function deliverPublish(env: RuntimeEnv, id: string): Promise<PublishDelivery> {
|
|
69
|
+
const current = await getDelivery(env, id);
|
|
70
|
+
if (current.status === 'accepted' || current.status === 'skipped') return toView(current);
|
|
71
|
+
if (current.status !== 'pending' && current.status !== 'failed') throw new PublishDeliveryConflictError('This build request cannot be retried');
|
|
72
|
+
|
|
73
|
+
if (current.status === 'pending' && current.next_retry_at && current.next_retry_at > Date.now()) {
|
|
74
|
+
throw new PublishDeliveryConflictError('This build request is still being sent');
|
|
75
|
+
}
|
|
76
|
+
const attempts = current.attempts + 1;
|
|
77
|
+
const started = await env.DB.prepare(`
|
|
78
|
+
UPDATE publish_delivery SET status='pending',attempts=?,last_error=NULL,next_retry_at=?
|
|
79
|
+
WHERE id=? AND status=? AND attempts=?`).bind(attempts, Date.now() + 30_000, id, current.status, current.attempts).run();
|
|
80
|
+
if (started.meta.changes !== 1) throw new PublishDeliveryConflictError('This build request changed. Reload and try again.');
|
|
81
|
+
|
|
82
|
+
if (!env.WORKERS_DEPLOY_HOOK_URL) {
|
|
83
|
+
await env.DB.prepare(`UPDATE publish_delivery SET status='skipped',last_error=?,next_retry_at=NULL WHERE id=?`)
|
|
84
|
+
.bind(env.LOCAL_DEV_BUILD === 'true' ? 'Local development rebuild is active' : 'WORKERS_DEPLOY_HOOK_URL is not configured', id).run();
|
|
85
|
+
return toView(await getDelivery(env, id));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const hook = await triggerDeployHook(env.WORKERS_DEPLOY_HOOK_URL);
|
|
90
|
+
await env.DB.prepare(`
|
|
91
|
+
UPDATE publish_delivery
|
|
92
|
+
SET status='accepted',build_id=?,already_exists=?,accepted_at=?,last_error=NULL,next_retry_at=NULL
|
|
93
|
+
WHERE id=?`).bind(hook.buildId, hook.alreadyExists ? 1 : 0, Date.now(), id).run();
|
|
94
|
+
} catch (error) {
|
|
95
|
+
const message = error instanceof Error ? error.message : 'Workers Deploy Hook request failed';
|
|
96
|
+
await env.DB.prepare(`UPDATE publish_delivery SET status='failed',last_error=?,next_retry_at=? WHERE id=?`)
|
|
97
|
+
.bind(message, null, id).run();
|
|
98
|
+
}
|
|
99
|
+
return toView(await getDelivery(env, id));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function requestPublish(env: RuntimeEnv, triggerKind: PublishDeliveryKind, documentTranslationId: string | null = null) {
|
|
103
|
+
const pending = await createPublishDelivery(env, triggerKind, documentTranslationId);
|
|
104
|
+
return deliverPublish(env, pending.id);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Publish the selected translation and create its delivery in one D1 batch. */
|
|
108
|
+
export async function publishDocumentAndRequest(env: RuntimeEnv, id: string, version: number, locale: SupportedLocale): Promise<{ document: DocumentView; delivery: PublishDelivery }> {
|
|
109
|
+
const pending = { id: crypto.randomUUID(), requestedAt: Date.now() };
|
|
110
|
+
const document = await publishDocument(env, id, version, locale, pending);
|
|
111
|
+
return { document, delivery: await deliverPublish(env, pending.id) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function retryPublish(env: RuntimeEnv, id: string) {
|
|
115
|
+
const delivery = await getDelivery(env, id);
|
|
116
|
+
if (delivery.status !== 'failed' && delivery.status !== 'pending') throw new PublishDeliveryConflictError('Only failed or pending build requests can be retried');
|
|
117
|
+
return deliverPublish(env, id);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function listPublishDeliveries(env: RuntimeEnv, limit = 10) {
|
|
121
|
+
const rows = await env.DB.prepare(`
|
|
122
|
+
SELECT id,trigger_kind,document_translation_id,status,attempts,build_id,already_exists,last_error,
|
|
123
|
+
requested_at,accepted_at,next_retry_at
|
|
124
|
+
FROM publish_delivery ORDER BY CASE WHEN status='failed' OR (status='pending' AND (next_retry_at IS NULL OR next_retry_at<=?)) THEN 0 ELSE 1 END, requested_at DESC LIMIT ?`).bind(Date.now(), limit).all<DeliveryRow>();
|
|
125
|
+
return rows.results.map(toView);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Dispatch newly persisted navigation/deletion changes; never create a second record. */
|
|
129
|
+
export async function deliverPendingChanges(env: RuntimeEnv) {
|
|
130
|
+
const rows = await env.DB.prepare("SELECT id FROM publish_delivery WHERE status='pending' AND attempts=0 ORDER BY requested_at LIMIT 100").all<{ id: string }>();
|
|
131
|
+
for (const row of rows.results) {
|
|
132
|
+
try { await deliverPublish(env, row.id); }
|
|
133
|
+
catch (error) { if (!(error instanceof PublishDeliveryConflictError)) throw error; }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Loader } from 'astro/loaders';
|
|
2
|
+
import { defaultLocale } from '../locales.ts';
|
|
3
|
+
import { publishedDocuments } from './schema.ts';
|
|
4
|
+
|
|
5
|
+
export function cmsLoader(options: { loadSnapshot: () => Promise<unknown> }): Loader {
|
|
6
|
+
return {
|
|
7
|
+
name: 'starlight-cms',
|
|
8
|
+
async load({ store, parseData, renderMarkdown, generateDigest }) {
|
|
9
|
+
const documents = publishedDocuments(await options.loadSnapshot());
|
|
10
|
+
const entries = await Promise.all(documents.map(async (doc) => {
|
|
11
|
+
const id = doc.locale === defaultLocale ? doc.slug : `${doc.locale}/${doc.slug}`;
|
|
12
|
+
return {
|
|
13
|
+
id, filePath: `src/content/docs/${id}.md`,
|
|
14
|
+
data: await parseData({ id, data: { title: doc.title, description: doc.description, sidebar: { order: doc.order }, editUrl: false } }),
|
|
15
|
+
body: doc.body.value, rendered: await renderMarkdown(doc.body.value), digest: generateDigest(doc),
|
|
16
|
+
}; }));
|
|
17
|
+
store.clear();
|
|
18
|
+
for (const entry of entries) store.set(entry);
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { documentUrl, parseTiptapNode, tiptapChildren, type TiptapNode } from './tiptap.ts';
|
|
2
|
+
import GithubSlugger from 'github-slugger';
|
|
3
|
+
|
|
4
|
+
export interface PreviewHeading {
|
|
5
|
+
id: string;
|
|
6
|
+
level: number;
|
|
7
|
+
text: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface PreviewDocument {
|
|
11
|
+
html: string;
|
|
12
|
+
headings: PreviewHeading[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function escapeHtml(value: string): string {
|
|
16
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function text(value: TiptapNode): string {
|
|
20
|
+
let output = escapeHtml(value.text ?? '');
|
|
21
|
+
const marks = Array.isArray(value.marks) ? value.marks : [];
|
|
22
|
+
for (const mark of marks) {
|
|
23
|
+
if (!mark || typeof mark !== 'object' || !('type' in mark)) continue;
|
|
24
|
+
const typed = mark as { type: unknown; attrs?: Record<string, unknown> };
|
|
25
|
+
switch (typed.type) {
|
|
26
|
+
case 'bold': output = `<strong>${output}</strong>`; break;
|
|
27
|
+
case 'italic': output = `<em>${output}</em>`; break;
|
|
28
|
+
case 'strike': output = `<s>${output}</s>`; break;
|
|
29
|
+
case 'code': output = `<code>${output}</code>`; break;
|
|
30
|
+
case 'link': output = `<a href="${escapeHtml(documentUrl(typed.attrs?.href))}">${output}</a>`; break;
|
|
31
|
+
default: throw new Error(`Unsupported Tiptap mark: ${String(typed.type)}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return output;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function asideTitle(value: unknown): string {
|
|
38
|
+
return typeof value === 'string' ? value.replace(/[\]\r\n]/g, ' ').trim() : 'Note';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function codeLanguage(value: unknown): string {
|
|
42
|
+
return typeof value === 'string' && /^[a-z0-9+-]+$/i.test(value) ? ` class="language-${escapeHtml(value)}"` : '';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function headingText(value: TiptapNode): string {
|
|
46
|
+
if (value.type === 'text') return value.text ?? '';
|
|
47
|
+
if (value.type === 'hardBreak') return ' ';
|
|
48
|
+
return tiptapChildren(value).map(headingText).join('');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function createRenderer() {
|
|
52
|
+
const headings: PreviewHeading[] = [];
|
|
53
|
+
const slugger = new GithubSlugger();
|
|
54
|
+
|
|
55
|
+
function inline(value: TiptapNode): string {
|
|
56
|
+
return tiptapChildren(value).map(renderNode).join('');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function renderListItem(value: TiptapNode): string {
|
|
60
|
+
return `<li>${inline(value)}</li>`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function renderTableRow(value: TiptapNode, header: boolean): string {
|
|
64
|
+
const cell = header ? 'th' : 'td';
|
|
65
|
+
return `<tr>${tiptapChildren(value).map((item) => `<${cell}>${inline(item)}</${cell}>`).join('')}</tr>`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function renderTabs(value: TiptapNode): string {
|
|
69
|
+
const tabs = tiptapChildren(value);
|
|
70
|
+
if (tabs.length === 0) return '';
|
|
71
|
+
const labels = tabs.map((tab) => typeof tab.attrs?.label === 'string' ? tab.attrs.label : 'Tab');
|
|
72
|
+
return `<starlight-tabs><div class="tablist-wrapper not-content"><ul role="tablist">${labels.map((label, index) => `<li role="presentation" class="tab"><a role="tab" href="#cms-preview-tab-${index}" aria-selected="${index === 0}"${index === 0 ? '' : ' tabindex="-1"'}>${escapeHtml(label)}</a></li>`).join('')}</ul></div>${tabs.map((tab, index) => `<section id="cms-preview-tab-${index}" role="tabpanel"${index === 0 ? '' : ' hidden'}>${inline(tab)}</section>`).join('')}</starlight-tabs>`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function renderNode(value: TiptapNode): string {
|
|
76
|
+
switch (value.type) {
|
|
77
|
+
case 'text': return text(value);
|
|
78
|
+
case 'doc': return tiptapChildren(value).map(renderNode).join('');
|
|
79
|
+
case 'paragraph': return `<p>${inline(value)}</p>`;
|
|
80
|
+
case 'heading': {
|
|
81
|
+
const level = Number(value.attrs?.level ?? 2);
|
|
82
|
+
if (!Number.isInteger(level) || level < 1 || level > 6) throw new Error('Invalid heading level');
|
|
83
|
+
const label = headingText(value).replace(/\s+/g, ' ').trim() || 'Section';
|
|
84
|
+
const id = slugger.slug(label);
|
|
85
|
+
headings.push({ id, level, text: label });
|
|
86
|
+
return `<h${level} id="${escapeHtml(id)}">${inline(value)}</h${level}>`;
|
|
87
|
+
}
|
|
88
|
+
case 'bulletList': return `<ul>${tiptapChildren(value).map(renderListItem).join('')}</ul>`;
|
|
89
|
+
case 'orderedList': return `<ol>${tiptapChildren(value).map(renderListItem).join('')}</ol>`;
|
|
90
|
+
case 'taskList': return `<ul class="contains-task-list">${tiptapChildren(value).map(renderNode).join('')}</ul>`;
|
|
91
|
+
case 'taskItem': return `<li class="task-list-item"><input type="checkbox" disabled${value.attrs?.checked === true ? ' checked' : ''}>${inline(value)}</li>`;
|
|
92
|
+
case 'listItem': return renderListItem(value);
|
|
93
|
+
case 'blockquote': return `<blockquote>${inline(value)}</blockquote>`;
|
|
94
|
+
case 'codeBlock': return `<pre><code${codeLanguage(value.attrs?.language)}>${inline(value)}</code></pre>`;
|
|
95
|
+
case 'hardBreak': return '<br>';
|
|
96
|
+
case 'horizontalRule': return '<hr>';
|
|
97
|
+
case 'image': return `<img src="${escapeHtml(documentUrl(value.attrs?.src))}" alt="${escapeHtml(typeof value.attrs?.alt === 'string' ? value.attrs.alt : '')}">`;
|
|
98
|
+
case 'video': return `<video controls src="${escapeHtml(documentUrl(value.attrs?.src))}"></video>`;
|
|
99
|
+
case 'callout': {
|
|
100
|
+
const title = asideTitle(value.attrs?.title);
|
|
101
|
+
return `<aside aria-label="${escapeHtml(title)}" class="starlight-aside starlight-aside--note"><p class="starlight-aside__title" aria-hidden="true">${escapeHtml(title)}</p><div class="starlight-aside__content">${inline(value)}</div></aside>`;
|
|
102
|
+
}
|
|
103
|
+
case 'steps': return `<ol class="sl-steps">${tiptapChildren(value).map(renderListItem).join('')}</ol>`;
|
|
104
|
+
case 'tabs': return renderTabs(value);
|
|
105
|
+
case 'tab': return inline(value);
|
|
106
|
+
case 'table': {
|
|
107
|
+
const rows = tiptapChildren(value);
|
|
108
|
+
return `<table><tbody>${rows.map((row, index) => renderTableRow(row, index === 0)).join('')}</tbody></table>`;
|
|
109
|
+
}
|
|
110
|
+
case 'tableRow': return renderTableRow(value, false);
|
|
111
|
+
case 'tableHeader':
|
|
112
|
+
case 'tableCell': return inline(value);
|
|
113
|
+
default: throw new Error(`Unsupported Tiptap node: ${value.type}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { headings, render: renderNode };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Render the supported CMS Tiptap subset into safe HTML inside Starlight's generated shell. */
|
|
121
|
+
export function renderPreviewContent(content: unknown): string {
|
|
122
|
+
return renderPreviewDocument(content).html;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Render the saved document once so heading anchors and the generated TOC always agree. */
|
|
126
|
+
export function renderPreviewDocument(content: unknown): PreviewDocument {
|
|
127
|
+
const renderer = createRenderer();
|
|
128
|
+
return { html: renderer.render(parseTiptapNode(content)), headings: renderer.headings };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
interface TocItem extends PreviewHeading { children: TocItem[]; }
|
|
132
|
+
|
|
133
|
+
function tocTree(headings: PreviewHeading[], minHeadingLevel: number, maxHeadingLevel: number): TocItem[] {
|
|
134
|
+
const roots: TocItem[] = [];
|
|
135
|
+
const add = (items: TocItem[], item: TocItem): void => {
|
|
136
|
+
const last = items.at(-1);
|
|
137
|
+
if (!last || last.level >= item.level) items.push(item);
|
|
138
|
+
else add(last.children, item);
|
|
139
|
+
};
|
|
140
|
+
for (const heading of headings) {
|
|
141
|
+
if (heading.level >= minHeadingLevel && heading.level <= maxHeadingLevel) add(roots, { ...heading, children: [] });
|
|
142
|
+
}
|
|
143
|
+
return roots;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function scopedClass(className: string): string {
|
|
147
|
+
return className ? ` class="${escapeHtml(className)}"` : '';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function renderTocItems(items: TocItem[], depth: number, className: string): string {
|
|
151
|
+
return items.map((item) => {
|
|
152
|
+
const children = item.children.length > 0
|
|
153
|
+
? `<ul${scopedClass(className)} style="--depth: ${depth + 1};">${renderTocItems(item.children, depth + 1, className)}</ul>`
|
|
154
|
+
: '';
|
|
155
|
+
return `<li${scopedClass(className)} style="--depth: ${depth};"><a${scopedClass(className)} href="#${encodeURIComponent(item.id)}" style="--depth: ${depth};"><span${scopedClass(className)} style="--depth: ${depth};">${escapeHtml(item.text)}</span></a>${children}</li>`;
|
|
156
|
+
}).join('');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Replace Starlight's static `Overview` item while retaining its generated TOC structure and styles. */
|
|
160
|
+
export function renderPreviewTocItems(headings: PreviewHeading[], minHeadingLevel: number, maxHeadingLevel: number, className: string): string {
|
|
161
|
+
return renderTocItems(tocTree(headings, minHeadingLevel, maxHeadingLevel), 0, className);
|
|
162
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
<StarlightPage frontmatter={{
|
|
6
|
+
title: 'Preview',
|
|
7
|
+
description: 'Saved draft preview.',
|
|
8
|
+
editUrl: false,
|
|
9
|
+
pagefind: false,
|
|
10
|
+
head: [{ tag: 'meta', attrs: { name: 'robots', content: 'noindex, nofollow' } }],
|
|
11
|
+
}} hasSidebar={true}>
|
|
12
|
+
<!-- The protected Worker replaces this marker with a saved Draft. -->
|
|
13
|
+
<div id="cms-preview-content"></div>
|
|
14
|
+
</StarlightPage>
|