diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..d232c8b --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,29 @@ +name: Deploy to Server + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build API image + run: | + docker build -t rustycms-api:latest . + + - name: Build Admin UI image + run: | + docker build \ + --build-arg NEXT_PUBLIC_RUSTYCMS_API_URL=https://cms.pm86.de \ + -t rustycms-admin:latest \ + ./admin-ui + + - name: Deploy + run: | + docker compose -f /opt/rustycms/docker-compose.yml up -d + docker image prune -f diff --git a/admin-ui/messages/de.json b/admin-ui/messages/de.json index 621f930..04e632d 100644 --- a/admin-ui/messages/de.json +++ b/admin-ui/messages/de.json @@ -32,6 +32,7 @@ "slugHint": "Kleinbuchstaben (a-z), Ziffern (0-9), Bindestriche. Leerzeichen werden zu Bindestrichen.", "savedSuccessfully": "Erfolgreich gespeichert.", "errorSaving": "Fehler beim Speichern", + "validationErrors": "Bitte korrigieren Sie die Fehler im Formular (z. B. Pflichtfelder).", "saving": "Speichern…", "save": "Speichern", "backToList": "Zurück zur Liste", @@ -175,7 +176,11 @@ "filterByTag": "Tag:", "tagAll": "Alle", "noResults": "Kein Inhaltstyp entspricht Suche oder Filter.", - "noCollections": "Keine Sammlungen geladen. Pr\u00fcfe ob die RustyCMS-API unter {url} erreichbar ist." + "noCollections": "Keine Sammlungen geladen. Pr\u00fcfe ob die RustyCMS-API unter {url} erreichbar ist.", + "recentSectionTitle": "Letzte 3 bearbeitete Beitr\u00e4ge", + "recentSectionLink": "Alle Beitr\u00e4ge", + "recentSectionEmpty": "Noch keine Beitr\u00e4ge.", + "loading": "Laden…" }, "TypesPage": { "title": "Typen", @@ -355,6 +360,8 @@ "newEntry": "Neuer Eintrag", "colActions": "Aktionen", "colStatus": "Status", + "colCreated": "Erstellt", + "colLastEdited": "Zuletzt geändert", "published": "Veröffentlicht", "noEntries": "Keine Einträge.", "noEntriesCreate": "Noch keine Einträge. Erstellen Sie den ersten.", @@ -382,6 +389,8 @@ }, "ContentEditPage": { "title": "Eintrag bearbeiten", + "created": "Erstellt", + "lastEdited": "Zuletzt geändert", "apiLink": "API-Link (Daten-Vorschau):", "referrersSection": "Referenziert von", "noReferrers": "Kein anderer Eintrag verweist auf diesen.", diff --git a/admin-ui/messages/en.json b/admin-ui/messages/en.json index c897232..204dde7 100644 --- a/admin-ui/messages/en.json +++ b/admin-ui/messages/en.json @@ -32,6 +32,7 @@ "slugHint": "Lowercase letters (a-z), digits (0-9), hyphens. Spaces become hyphens.", "savedSuccessfully": "Saved successfully.", "errorSaving": "Error saving", + "validationErrors": "Please fix the errors in the form (e.g. required fields).", "saving": "Saving…", "save": "Save", "backToList": "Back to list", @@ -175,7 +176,11 @@ "filterByTag": "Tag:", "tagAll": "All", "noResults": "No content types match your search or filter.", - "noCollections": "No collections loaded. Check that the RustyCMS API is running at {url}." + "noCollections": "No collections loaded. Check that the RustyCMS API is running at {url}.", + "recentSectionTitle": "Last 3 edited posts", + "recentSectionLink": "All posts", + "recentSectionEmpty": "No posts yet.", + "loading": "Loading…" }, "TypesPage": { "title": "Types", @@ -355,6 +360,8 @@ "newEntry": "New entry", "colActions": "Actions", "colStatus": "Status", + "colCreated": "Created", + "colLastEdited": "Last edited", "published": "Published", "noEntries": "No entries.", "noEntriesCreate": "No entries yet. Create the first one.", @@ -382,6 +389,8 @@ }, "ContentEditPage": { "title": "Edit entry", + "created": "Created", + "lastEdited": "Last edited", "apiLink": "API link (data preview):", "referrersSection": "Referenced by", "noReferrers": "No other entries reference this one.", diff --git a/admin-ui/next.config.ts b/admin-ui/next.config.ts index 7e5afee..cd2689e 100644 --- a/admin-ui/next.config.ts +++ b/admin-ui/next.config.ts @@ -4,4 +4,5 @@ const withNextIntl = createNextIntlPlugin('./i18n/request.ts'); export default withNextIntl({ output: 'standalone', + basePath: '/admin', }); diff --git a/admin-ui/package-lock.json b/admin-ui/package-lock.json index c0d0259..2f73b76 100644 --- a/admin-ui/package-lock.json +++ b/admin-ui/package-lock.json @@ -24,6 +24,7 @@ "react-hook-form": "^7.71.1", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", + "rehype-raw": "^7.0.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss-animate": "^1.0.7" @@ -6316,6 +6317,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.24.1", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", @@ -7415,6 +7428,26 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-parse-selector": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", @@ -7428,6 +7461,31 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -7455,6 +7513,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-whitespace": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", @@ -7527,6 +7604,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/icu-minify": { "version": "4.8.3", "resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.8.3.tgz", @@ -9718,6 +9805,18 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -10201,6 +10300,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", @@ -11424,6 +11538,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vfile-message": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", @@ -11438,6 +11566,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/admin-ui/package.json b/admin-ui/package.json index 921aac2..e047f3b 100644 --- a/admin-ui/package.json +++ b/admin-ui/package.json @@ -25,6 +25,7 @@ "react-hook-form": "^7.71.1", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", + "rehype-raw": "^7.0.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss-animate": "^1.0.7" diff --git a/admin-ui/src/app/content/[collection]/[slug]/page.tsx b/admin-ui/src/app/content/[collection]/[slug]/page.tsx index af56819..852c106 100644 --- a/admin-ui/src/app/content/[collection]/[slug]/page.tsx +++ b/admin-ui/src/app/content/[collection]/[slug]/page.tsx @@ -204,9 +204,25 @@ export default function ContentEditPage() { )} -

+

{t("title")} — {slug}

+ {entry && (entry._created != null || entry._updated != null) && ( +

+ {entry._created != null && ( + + {t("created")}: {new Date(String(entry._created)).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" })} + + )} + {entry._created != null && entry._updated != null && " · "} + {entry._updated != null && ( + + {t("lastEdited")}: {new Date(String(entry._updated)).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" })} + + )} +

+ )} + {entry && !entry._created && !entry._updated &&
} {isLoading && (
diff --git a/admin-ui/src/app/content/[collection]/page.tsx b/admin-ui/src/app/content/[collection]/page.tsx index 2f399fd..f5084a4 100644 --- a/admin-ui/src/app/content/[collection]/page.tsx +++ b/admin-ui/src/app/content/[collection]/page.tsx @@ -104,6 +104,20 @@ export default function ContentListPage() { }; const isSortSlug = sort === "_slug" || !sort; const nextSlugOrder = isSortSlug && order === "asc" ? "desc" : "asc"; + const isSortCreated = sort === "_created"; + const nextCreatedOrder = isSortCreated && order === "desc" ? "asc" : "desc"; + const isSortUpdated = sort === "_updated"; + const nextUpdatedOrder = isSortUpdated && order === "desc" ? "asc" : "desc"; + + function formatListDate(iso: string | undefined): string { + if (!iso) return "—"; + try { + const d = new Date(iso); + return d.toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" }); + } catch { + return iso; + } + } const tBread = useTranslations("Breadcrumbs"); @@ -245,7 +259,7 @@ export default function ContentListPage() { _slug @@ -259,6 +273,38 @@ export default function ContentListPage() { {t("colStatus")} + + + {t("colCreated")} + {isSortCreated && ( + + )} + + + + + {t("colLastEdited")} + {isSortUpdated && ( + + )} + + {t("colActions")} @@ -273,7 +319,7 @@ export default function ContentListPage() { {slug} @@ -290,6 +336,12 @@ export default function ContentListPage() { {isDraft ? t("draft") : t("published")} + + {formatListDate(entry._created as string | undefined)} + + + {formatListDate(entry._updated as string | undefined)} +
+
); diff --git a/admin-ui/src/components/ContentForm.tsx b/admin-ui/src/components/ContentForm.tsx index 74c1faf..0e4707d 100644 --- a/admin-ui/src/components/ContentForm.tsx +++ b/admin-ui/src/components/ContentForm.tsx @@ -645,8 +645,17 @@ export function ContentForm({ flush(); } + const onInvalid = (errs: Record) => { + const firstKey = Object.keys(errs).find((k) => k !== "root" && errs[k]); + if (firstKey) { + const el = document.querySelector(`[name="${firstKey}"], [data-field="${firstKey}"]`); + if (el instanceof HTMLElement) el.scrollIntoView({ behavior: "smooth", block: "center" }); + } + toast.error(t("validationErrors")); + }; + return ( -
+ {errors.root && (
)} {showSlugField && ( - +
+ +
)} -
+
{item.entries.map(([name, def]) => ( -
+
) : ( -
+
c.name === RECENT_COLLECTION); + + const { data, isLoading, error } = useQuery({ + queryKey: ["content", RECENT_COLLECTION, "_updated", "desc", RECENT_LIMIT], + queryFn: () => + fetchContentList(RECENT_COLLECTION, { + _sort: "_updated", + _order: "desc", + _per_page: RECENT_LIMIT, + _status: "all", + }), + enabled: hasPost, + }); + + if (!hasPost) return null; + const items = (data?.items ?? []) as Record[]; + + return ( +
+
+

+ {t("recentSectionTitle")} +

+ + {t("recentSectionLink")} + +
+ {isLoading &&

{t("loading")}

} + {error && ( +

+ {error instanceof Error ? error.message : String(error)} +

+ )} + {!isLoading && !error && items.length === 0 && ( +

{t("recentSectionEmpty")}

+ )} + {!isLoading && !error && items.length > 0 && ( +
    + {items.map((entry) => { + const slug = entry._slug as string | undefined; + if (!slug) return null; + const updated = entry._updated as string | undefined; + return ( +
  • + + + {slug} + + {updated && ( + + {formatListDate(updated)} + + )} + +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/admin-ui/src/components/MarkdownEditor.tsx b/admin-ui/src/components/MarkdownEditor.tsx index 8c3571d..1eb7b14 100644 --- a/admin-ui/src/components/MarkdownEditor.tsx +++ b/admin-ui/src/components/MarkdownEditor.tsx @@ -1,8 +1,10 @@ "use client"; -import { useId, useRef } from "react"; +import { useId, useRef, useMemo } from "react"; import ReactMarkdown from "react-markdown"; +import rehypeRaw from "rehype-raw"; import { useTranslations } from "next-intl"; +import { getBaseUrl, expandAssetUrlsInText } from "@/lib/api"; type Props = { value: string; @@ -57,6 +59,10 @@ export function MarkdownEditor({ const id = useId(); const previewId = `${id}-preview`; const textareaRef = useRef(null); + const previewMarkdown = useMemo( + () => expandAssetUrlsInText(value ?? "", getBaseUrl()), + [value] + ); const apply = (result: { newText: string; newStart: number; newEnd: number }) => { onChange(result.newText); @@ -162,7 +168,7 @@ export function MarkdownEditor({ className="min-h-[120px] rounded border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-800 [&_ul]:list-inside [&_ul]:list-disc [&_ol]:list-inside [&_ol]:list-decimal [&_pre]:overflow-x-auto [&_pre]:rounded [&_pre]:bg-gray-200 [&_pre]:p-2 [&_code]:rounded [&_code]:bg-gray-200 [&_code]:px-1 [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_h1]:text-lg [&_h1]:font-bold [&_h2]:text-base [&_h2]:font-bold [&_h3]:text-sm [&_h3]:font-bold [&_a]:text-accent-700 [&_a]:underline [&_a:hover]:text-accent-800" > {(value ?? "").trim() ? ( - {value} + {previewMarkdown} ) : ( {t("emptyPreview")} )} diff --git a/admin-ui/src/lib/api.ts b/admin-ui/src/lib/api.ts index 1d1e4b0..32d443f 100644 --- a/admin-ui/src/lib/api.ts +++ b/admin-ui/src/lib/api.ts @@ -6,6 +6,21 @@ export const getBaseUrl = () => process.env.NEXT_PUBLIC_RUSTYCMS_API_URL || "http://127.0.0.1:3000"; +/** + * Expand relative /api/assets/ paths in text (e.g. markdown) to full URLs for preview/display. + * Idempotent: already-expanded base URL is preserved. + */ +export function expandAssetUrlsInText(text: string, baseUrl: string): string { + const base = baseUrl.replace(/\/+$/, ""); + const fullPrefix = `${base}/api/assets/`; + if (!text.includes("/api/assets/")) return text; + const placeholder = "\x00__ASSET_BASE__\x00"; + return text + .replaceAll(fullPrefix, placeholder) + .replaceAll("/api/assets/", fullPrefix) + .replaceAll(placeholder, fullPrefix); +} + /** * Recursively collapse absolute asset URLs to relative paths so the admin * keeps and displays /api/assets/... and the backend stores relative. @@ -14,10 +29,11 @@ export function collapseAssetUrlsForAdmin( value: unknown, baseUrl: string ): unknown { - const prefix = `${baseUrl.replace(/\/+$/, "")}/api/assets/`; + const base = baseUrl.replace(/\/+$/, ""); + const prefix = `${base}/api/assets/`; if (typeof value === "string") { - if (value.startsWith(prefix)) return `/api/assets/${value.slice(prefix.length)}`; - return value; + if (!value.includes(prefix)) return value; + return value.replaceAll(prefix, "/api/assets/"); } if (Array.isArray(value)) return value.map((v) => collapseAssetUrlsForAdmin(v, baseUrl)); if (value != null && typeof value === "object") { diff --git a/src/api/handlers.rs b/src/api/handlers.rs index cc1e4a8..4768345 100644 --- a/src/api/handlers.rs +++ b/src/api/handlers.rs @@ -570,9 +570,7 @@ pub async fn list_entries( Some(&*registry), ) .await; - if resolve.is_some() { - expand_asset_urls(item, &state.base_url); - } + expand_asset_urls(item, &state.base_url); } let response_value = serde_json::to_value(&result).unwrap(); @@ -676,9 +674,7 @@ pub async fn get_entry( Some(&*registry), ) .await; - if resolve.is_some() { - expand_asset_urls(&mut formatted, &state.base_url); - } + expand_asset_urls(&mut formatted, &state.base_url); // Only cache published entries so unauthenticated requests never see cached drafts. if !entry_is_draft(&formatted) { @@ -844,7 +840,7 @@ pub async fn create_entry( .await .map_err(ApiError::from)? .unwrap(); - let formatted = format_references( + let mut formatted = format_references( entry, &schema, store.as_ref(), @@ -853,6 +849,7 @@ pub async fn create_entry( None, ) .await; + expand_asset_urls(&mut formatted, &state.base_url); webhooks::fire( state.http_client.clone(), @@ -1003,7 +1000,7 @@ pub async fn update_entry( .await .map_err(ApiError::from)? .unwrap(); - let formatted = format_references( + let mut formatted = format_references( entry, &schema, store.as_ref(), @@ -1012,6 +1009,7 @@ pub async fn update_entry( None, ) .await; + expand_asset_urls(&mut formatted, &state.base_url); webhooks::fire( state.http_client.clone(), diff --git a/src/api/response.rs b/src/api/response.rs index dc710eb..5cc7083 100644 --- a/src/api/response.rs +++ b/src/api/response.rs @@ -9,11 +9,19 @@ use crate::schema::SchemaRegistry; use crate::store::ContentStore; /// Recursively expand relative `/api/assets/` paths to absolute URLs. -/// Idempotent: strings already starting with a scheme (http/https) are skipped. +/// Whole-string values and occurrences inside strings (e.g. markdown) are expanded. +/// Idempotent: already-expanded base URL is preserved via placeholder during replace. pub fn expand_asset_urls(value: &mut Value, base_url: &str) { + let base = base_url.trim_end_matches('/'); + let full_prefix = format!("{}/api/assets/", base); match value { - Value::String(s) if s.starts_with("/api/assets/") => { - *s = format!("{}{}", base_url.trim_end_matches('/'), s); + Value::String(s) if s.contains("/api/assets/") => { + // Avoid double-expand: temporarily replace already-expanded URLs + const PLACEHOLDER: &str = "\x00__ASSET_BASE__\x00"; + *s = s + .replace(&full_prefix, PLACEHOLDER) + .replace("/api/assets/", &full_prefix) + .replace(PLACEHOLDER, &full_prefix); } Value::Array(arr) => arr.iter_mut().for_each(|v| expand_asset_urls(v, base_url)), Value::Object(map) => map.values_mut().for_each(|v| expand_asset_urls(v, base_url)), @@ -23,11 +31,12 @@ pub fn expand_asset_urls(value: &mut Value, base_url: &str) { /// Reverse of expand_asset_urls: strip the base_url prefix from absolute asset URLs /// before persisting to disk, so stored paths are always relative. +/// Whole-string values and occurrences inside strings (e.g. markdown) are collapsed. pub fn collapse_asset_urls(value: &mut Value, base_url: &str) { let prefix = format!("{}/api/assets/", base_url.trim_end_matches('/')); match value { - Value::String(s) if s.starts_with(&prefix) => { - *s = format!("/api/assets/{}", &s[prefix.len()..]); + Value::String(s) if s.contains(&prefix) => { + *s = s.replace(&prefix, "/api/assets/"); } Value::Array(arr) => arr.iter_mut().for_each(|v| collapse_asset_urls(v, base_url)), Value::Object(map) => map.values_mut().for_each(|v| collapse_asset_urls(v, base_url)), diff --git a/src/store/filesystem.rs b/src/store/filesystem.rs index 2d42aa5..ad5c4d9 100644 --- a/src/store/filesystem.rs +++ b/src/store/filesystem.rs @@ -2,6 +2,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use async_trait::async_trait; +use chrono::{DateTime, Utc}; use serde_json::Value; use tokio::fs; use tokio::io::AsyncWriteExt; @@ -34,6 +35,32 @@ impl FileStore { .join(format!("{}.json5", slug)) } + /// Attach _created and _updated (ISO8601) from file metadata when available. + async fn attach_file_timestamps(&self, path: &Path, value: &mut Value) { + let meta = match fs::metadata(path).await { + Ok(m) => m, + Err(_) => return, + }; + let modified = match meta.modified() { + Ok(t) => t, + Err(_) => return, + }; + let modified_dt: DateTime = modified.into(); + let updated = modified_dt.to_rfc3339(); + let created = meta + .created() + .ok() + .map(|t| { + let dt: DateTime = t.into(); + dt.to_rfc3339() + }) + .unwrap_or_else(|| updated.clone()); + if let Some(obj) = value.as_object_mut() { + obj.insert("_created".to_string(), Value::String(created)); + obj.insert("_updated".to_string(), Value::String(updated)); + } + } + /// Path for optional external content file: same dir as entry, stem.content.md. /// Used so markdown (and other long text) can live in a .md file instead of JSON. fn content_file_path(&self, entry_path: &Path) -> PathBuf { @@ -178,6 +205,7 @@ impl FileStore { match self.read_file(&path).await { Ok(mut value) => { let _ = self.resolve_content_file(&path, &mut value).await; + self.attach_file_timestamps(&path, &mut value).await; if let Some(obj) = value.as_object_mut() { obj.insert("_slug".to_string(), Value::String(slug.clone())); } @@ -201,6 +229,7 @@ impl FileStore { let mut value = self.read_file(&path).await?; self.resolve_content_file(&path, &mut value).await?; + self.attach_file_timestamps(&path, &mut value).await; if let Some(obj) = value.as_object_mut() { obj.insert("_slug".to_string(), Value::String(slug.to_string())); }