Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -0,0 +1,57 @@
import { Link } from "react-router";
import type { KbArticleDto } from "../dtos/KbArticleDto";
import ColorTextUtils from "~/utils/shared/colors/ColorTextUtils";
import type { Colors } from "~/application/enums/shared/Colors";
import clsx from "clsx";
import type { KnowledgeBaseDto } from "../dtos/KnowledgeBaseDto";
import EmptyState from "~/components/ui/emptyState/EmptyState";
import { useTranslation } from "react-i18next";
import { ChevronRightIcon, StarIcon } from "~/components/ui/icons/vendor";
export default function KbFeaturedArticles({ kb, items }: { kb: KnowledgeBaseDto; items: KbArticleDto[] }) {
const { t } = useTranslation();
return (
<div className="space-y-4">
<div className="flex items-center space-x-4">
<FeaturedArticlesIcon color={kb.color} />
<h2 className="text-2xl font-bold">{t("knowledgeBase.featuredArticles")}</h2>
</div>
{items.length === 0 && <EmptyState className="bg-background" captions={{ thereAreNo: t("knowledgeBase.article.emptyFeatured") }} />}
<div className={clsx("grid gap-2", items.length === 1 ? "grid-cols-1" : "sm:grid-cols-2")}>
{items.map((item) => {
return (
<Link
to={item.href}
key={item.id}
className="rounded-md border border-border bg-background px-3 py-4 shadow-2xs hover:bg-secondary hover:border-secondary-foreground"
>
<div className="flex items-center justify-between space-x-2">
<div className="grow font-medium">{item.title}</div>
<div className="shrink-0">
{/* <svg className="h-5 w-5" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24">
<path d="M5 12h14M12 5l7 7-7 7"></path>
</svg> */}
<ChevronRightIcon className="h-5 w-5" />
</div>
</div>
</Link>
);
})}
</div>
</div>
);
}
function FeaturedArticlesIcon({ color }: { color: Colors }) {
return (
<StarIcon className={clsx("h-5 w-5", ColorTextUtils.getText500(color))} />
);
}
// function CategoriesIcon() {
// return (
// <svg className="h-7 w-7" fill="currentColor" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 48 48">
// <path d="M 10.5 5 C 8.019 5 6 7.019 6 9.5 L 6 31.5 C 6 33.981 8.019 36 10.5 36 L 16 36 L 16 33 L 10.5 33 C 9.673 33 9 32.327 9 31.5 L 9 31 L 16 31 L 16 16.5 C 16 16.331 16.013391 16.166 16.025391 16 L 13.5 16 C 12.671 16 12 15.329 12 14.5 C 12 13.671 12.671 13 13.5 13 L 17.03125 13 C 18.18825 11.199 20.205 10 22.5 10 L 30 10 L 30 9.5 C 30 7.019 27.981 5 25.5 5 L 10.5 5 z M 22.5 12 C 20.019 12 18 14.019 18 16.5 L 18 38.5 C 18 40.981 20.019 43 22.5 43 L 40.5 43 C 41.329 43 42 42.329 42 41.5 C 42 40.671 41.329 40 40.5 40 L 22.5 40 C 21.673 40 21 39.327 21 38.5 L 21 38 L 40.5 38 C 41.329 38 42 37.329 42 36.5 L 42 16.5 C 42 14.019 39.981 12 37.5 12 L 22.5 12 z M 25.5 20 L 34.5 20 C 35.329 20 36 20.671 36 21.5 C 36 22.329 35.329 23 34.5 23 L 25.5 23 C 24.671 23 24 22.329 24 21.5 C 24 20.671 24.671 20 25.5 20 z"></path>
// </svg>
// );
// }
@@ -0,0 +1,106 @@
import { Link, useNavigate, useParams, useSearchParams } from "react-router";
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
import type { KnowledgeBaseDto } from "../dtos/KnowledgeBaseDto";
import clsx from "clsx";
import ColorBackgroundUtils from "~/utils/shared/colors/ColorBackgroundUtils";
import ColorTextUtils from "~/utils/shared/colors/ColorTextUtils";
import ColorFocusUtils from "~/utils/shared/colors/ColorFocusUtils";
import ColorRingUtils from "~/utils/shared/colors/ColorRingUtils";
import ColorGradientUtils from "~/utils/shared/colors/ColorGradientUtils";
import { logoLight, logoDark } from "~/assets/brand/default";
import KnowledgeBaseUtils from "../utils/KnowledgeBaseUtils";
import KbSearch from "./KbSearch";
interface Props {
kb: KnowledgeBaseDto;
// currentVersion: string | undefined;
withTitleAndDescription: boolean;
withSearch?: boolean;
}
export default function KbHeader({ kb, withTitleAndDescription, withSearch = true }: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const params = useParams();
const [searchParams] = useSearchParams();
const logoAlt = t("knowledgeBase.header.logoAlt", { title: kb.title });
return (
<div>
<div className={clsx("bg-linear-to-r text-white", ColorGradientUtils.getFrom800To900(kb.color))}>
<div className="mx-auto max-w-5xl space-y-8 px-8 py-6">
<div className="flex h-12 items-center justify-between space-x-2">
<Link to={KnowledgeBaseUtils.getKbUrl({ kb, params })} className="flex select-none items-center space-x-2">
{kb.logo === "light" ? (
<img className="h-8 w-auto" src={logoLight} alt={logoAlt} />
) : kb.logo === "dark" ? (
<img className="h-8 w-auto" src={logoDark} alt={logoAlt} />
) : (
<img className="h-8 w-auto" src={kb.logo} alt={logoAlt} />
)}
{!withTitleAndDescription && (
<Fragment>
<span className={clsx("hidden px-2 sm:block", ColorTextUtils.getText300(kb.color))}>|</span>
<span className="hidden font-semibold sm:block">{kb.title}</span>
</Fragment>
)}
</Link>
<div className="flex items-center space-x-4">
{kb.links.map((link) => {
return (
<a key={link.href} href={link.href} className={clsx("transition-colors duration-150 hover:text-white", ColorTextUtils.getText300(kb.color))}>
{link.name}
</a>
);
})}
<div className="flex items-center space-x-2">
{kb.languages.length > 1 && (
<select
id="lang"
name="lang"
defaultValue={params.lang || kb.defaultLanguage}
className={clsx(
"block w-full rounded-md border-0 py-1.5 pl-3 pr-10 text-white ring-1 ring-inset focus:ring-2 sm:text-sm sm:leading-6",
ColorBackgroundUtils.getBg700(kb.color),
ColorRingUtils.getRing800(kb.color),
ColorFocusUtils.getRing600(kb.color)
)}
onChange={(e) => {
navigate(
`${KnowledgeBaseUtils.getKbUrl({
kb,
params: {
lang: e.target.value,
},
})}?${searchParams.toString()}`
);
}}
>
{kb.languages.map((lang) => {
const language = KnowledgeBaseUtils.supportedLanguages.find((f) => f.value === lang);
if (!language) return null;
return (
<Fragment key={lang}>
<option key={lang} value={lang}>
{KnowledgeBaseUtils.getLanguageName(language.value, t)}
</option>
</Fragment>
);
})}
</select>
)}
</div>
</div>
</div>
{withTitleAndDescription && (
<div className="space-y-4">
<h1 className="text-2xl font-semibold tracking-tight sm:text-4xl">{kb.title}</h1>
<p className={clsx("text-base", ColorTextUtils.getText300(kb.color))}>{kb.description}</p>
</div>
)}
{withSearch && <KbSearch kb={kb} autoFocus={withTitleAndDescription} />}
</div>
</div>
</div>
);
}
@@ -0,0 +1,162 @@
import clsx from "clsx";
import { InfoIcon, SearchIcon, XIcon } from "~/components/ui/icons/vendor";
import ColorBackgroundUtils from "~/utils/shared/colors/ColorBackgroundUtils";
import ColorFocusUtils from "~/utils/shared/colors/ColorFocusUtils";
import ColorFocusWithinUtils from "~/utils/shared/colors/ColorFocusWithinUtils";
import ColorPlaceholderUtils from "~/utils/shared/colors/ColorPlaceholderUtils";
import ColorRingUtils from "~/utils/shared/colors/ColorRingUtils";
import ColorTextUtils from "~/utils/shared/colors/ColorTextUtils";
import { KnowledgeBaseDto } from "../dtos/KnowledgeBaseDto";
import { Link, useLoaderData, useSearchParams } from "react-router";
import { useState, useEffect } from "react";
import { useDebounce } from "use-debounce";
import { KbSearchResultDto } from "../dtos/KbSearchResultDto";
import ColorBorderUtils from "~/utils/shared/colors/ColorBorderUtils";
import { Colors } from "~/application/enums/shared/Colors";
import { KbArticleDto } from "../dtos/KbArticleDto";
import ColorHoverUtils from "~/utils/shared/colors/ColorHoverUtils";
import { useTranslation } from "react-i18next";
export default function KbSearch({ kb, autoFocus }: { kb: KnowledgeBaseDto; autoFocus: boolean }) {
const { t } = useTranslation();
let data = useLoaderData<
| {
search: KbSearchResultDto | undefined;
}
| undefined
>();
let [search, setSearch] = useState(data?.search?.query);
let [debouncedQuery] = useDebounce(search, 500);
const [searchParams, setSearchParams] = useSearchParams();
useEffect(() => {
if (debouncedQuery) {
searchParams.set("q", debouncedQuery);
} else {
searchParams.delete("q");
}
if (searchParams.toString()) {
setSearchParams(searchParams);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedQuery]);
return (
<div className="w-full space-y-2">
<div className={clsx("relative", ColorTextUtils.getText400(kb.color), ColorFocusWithinUtils.getText600(kb.color))}>
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4">
<SearchIcon className="h-5 w-5" />
</div>
<input
autoFocus={autoFocus}
autoComplete="off"
className={clsx(
" focus:bg-background block w-full rounded-md border-0 px-6 py-5 pl-12 outline-hidden ring-1 ring-inset transition-all focus:ring-2 focus:ring-inset",
ColorBackgroundUtils.getBg800(kb.color),
ColorTextUtils.getText300(kb.color),
ColorPlaceholderUtils.getText400(kb.color),
ColorFocusUtils.getText600(kb.color),
ColorFocusUtils.getPlaceholder600(kb.color),
ColorFocusUtils.getRing600(kb.color),
ColorRingUtils.getRing800(kb.color)
)}
type="text"
aria-label={t("knowledgeBase.search.label")}
placeholder={t("knowledgeBase.search.placeholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && (
<button type="button" className="absolute inset-y-0 right-0 flex items-center pr-4" onClick={() => setSearch("")} aria-label={t("knowledgeBase.search.clear")}>
<XIcon className="h-5 w-5" />
</button>
)}
</div>
{search && data?.search !== undefined && (
<div>
{!data.search || data.search.articles?.length === 0 ? (
<div className={clsx("rounded-md border", ColorBorderUtils.get300(kb.color), ColorBackgroundUtils.getBg50(kb.color))}>
<div className="rounded-md p-4">
<div className="flex">
<div className="shrink-0">
<InfoIcon className={clsx("h-5 w-5", ColorTextUtils.getText400(kb.color))} />
</div>
<div className="ml-3 w-full">
<h3 className={clsx("text-sm font-medium leading-5", ColorTextUtils.getText800(kb.color))}>
{t("knowledgeBase.search.noResults", { query: data.search.query })}
</h3>
<div className={clsx("mt-2 text-sm leading-5", ColorTextUtils.getText800(kb.color))}>
<div>
<p className="text-sm">{t("knowledgeBase.search.tryDifferent")}</p>
</div>
</div>
</div>
</div>
</div>
</div>
) : (
<div>
<div className="space-y-2">
{data.search.articles.map((item) => (
<Card key={item.id} kb={kb} item={item} highlightText={debouncedQuery ?? ""} />
))}
</div>
</div>
)}
</div>
)}
</div>
);
}
function Highlight({ className, color, children, highlightText }: { className: string; color: Colors; children: string; highlightText: string }) {
const parts = children.split(new RegExp(`(${highlightText})`, "gi"));
return (
<span className={clsx(className, " line-clamp-3")}>
{parts.map((part, i) =>
part.toLowerCase() === highlightText.toLowerCase() ? (
<span key={i} className={clsx(ColorBackgroundUtils.getBg400(color), ColorTextUtils.getText100(color))}>
{part}
</span>
) : (
part
)
)}
</span>
);
}
function Card({ kb, item, highlightText }: { kb: KnowledgeBaseDto; item: KbArticleDto; highlightText: string }) {
const contentLines = item.contentPublishedAsText.split("\n");
const highlightedLines = contentLines
.map((line, i) => ({ line, isHighlighted: line.toLowerCase().includes(highlightText.toLowerCase()), i }))
.filter(({ isHighlighted }) => isHighlighted)
.slice(0, 3);
return (
<div key={item.title} className={clsx("group rounded-md p-2 shadow-md", ColorBackgroundUtils.getBg800(kb.color), ColorHoverUtils.getBg900(kb.color))}>
<Link to={item.href} className="w-full">
<div className={clsx("space-y-1")}>
<h2>
<Highlight className={clsx(ColorTextUtils.getText100(kb.color))} color={kb.color} highlightText={highlightText}>
{item.title}
</Highlight>
</h2>
<p>
<Highlight className={clsx(ColorTextUtils.getText300(kb.color))} color={kb.color} highlightText={highlightText}>
{item.description}
</Highlight>
</p>
{highlightedLines.map(({ line, i }) => (
<p key={i}>
<Highlight className={clsx(ColorTextUtils.getText300(kb.color))} color={kb.color} highlightText={highlightText}>
{line}
</Highlight>
</p>
))}
</div>
</Link>
</div>
);
}
@@ -0,0 +1,28 @@
export default function KbSkeletons({ n = 5 }: { n?: number }) {
return (
<div className="space-y-3">
{Array.from({ length: n }).map((_, i) => {
return (
<div key={i} className="border-border hover:border-theme-500 bg-background group rounded-md border">
<div className="w-full">
<div className="flex items-center space-x-8 p-6">
<div className="shrink-0">
<div className="h-12 w-12 animate-pulse rounded-md bg-gray-300"></div>
</div>
<div className="flex flex-col">
<div className="h-4 w-32 animate-pulse rounded-md bg-gray-300 font-bold"></div>
<div className="mt-2 h-3 w-48 animate-pulse rounded-md bg-gray-300 text-sm"></div>
<div className="mt-6 flex items-center space-x-2">
<div className="h-4 w-16 animate-pulse rounded-md bg-gray-300 text-sm"></div>
<div className="text-sm text-gray-300">|</div>
<div className="h-4 w-48 animate-pulse rounded-md bg-gray-300 text-sm"></div>
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,55 @@
import type { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
import BreadcrumbSimple from "~/components/ui/breadcrumbs/BreadcrumbSimple";
import type { KbArticleDto } from "~/modules/knowledgeBase/dtos/KbArticleDto";
import type { KbCategoryDto } from "~/modules/knowledgeBase/dtos/KbCategoryDto";
import KbArticleContent from "./KbArticleContent";
import { useParams } from "react-router";
import KnowledgeBaseUtils from "../../utils/KnowledgeBaseUtils";
export default function KbArticle({
kb,
category,
item,
userState,
actions,
withMenu = true,
}: {
kb: KnowledgeBaseDto;
category: KbCategoryDto;
item: KbArticleDto;
userState: {
hasThumbsUp: boolean;
hasThumbsDown: boolean;
};
actions: {
onThumbsUp: () => void;
onThumbsDown: () => void;
};
withMenu?: boolean;
}) {
const params = useParams();
return (
<div className="space-y-6">
{withMenu && (
<BreadcrumbSimple
menu={[
// {
// title: kb.title,
// routePath: KnowledgeBaseUtils.getKbUrl({ kb, params }),
// },
{
title: category.title,
routePath: KnowledgeBaseUtils.getCategoryUrl({ kb, category, params }),
},
{
title: item.title,
routePath: item.href,
},
]}
/>
)}
<KbArticleContent item={item} content={item.contentPublished} userState={userState} actions={actions} />
</div>
);
}
@@ -0,0 +1,98 @@
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import ThumbsDownIcon from "~/components/ui/icons/ThumbsDownIcon";
import ThumbsDownIconFilled from "~/components/ui/icons/ThumbsDownIconFilled";
import ThumbsUpIcon from "~/components/ui/icons/ThumbsUpIcon";
import ThumbsUpIconFilled from "~/components/ui/icons/ThumbsUpIconFilled";
import { KbArticleDto } from "~/modules/knowledgeBase/dtos/KbArticleDto";
import { renderSanitizedMarkdown } from "~/utils/html-sanitizer";
import { useLocaleFormatters } from "~/locale/format";
export default function KbArticleContent({
item,
content,
actions,
userState,
}: {
item: KbArticleDto;
content: string;
userState?: {
hasThumbsUp: boolean;
hasThumbsDown: boolean;
};
actions?: {
onThumbsUp: () => void;
onThumbsDown: () => void;
};
}) {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
return (
<div>
<div className="flex flex-col space-y-6">
<div className="flex items-center space-x-8">
<div className="flex w-full flex-col">
<div className="flex items-center space-x-3">
<h1 className="text-xl font-bold md:text-3xl">{item.title}</h1>
</div>
<p className="text-muted-foreground mt-2 font-light">{item.description}</p>
<div className="flex justify-between space-x-2">
<li className="mt-5 flex items-center space-x-2">
{item.createdByUser?.avatar && (
<img
src={item.createdByUser.avatar}
alt={`${item.createdByUser.firstName} ${item.createdByUser.lastName}`}
className="h-8 w-8 rounded-full"
/>
)}
<div>
{item.createdByUser && (
<dl className="whitespace-no-wrap text-sm font-medium leading-5">
<dt className="sr-only">{t("knowledgeBase.article.author")}</dt>
<dd className="">
<span className="text-muted-foreground font-normal">{t("shared.by")}</span> {item.createdByUser.firstName} {item.createdByUser.lastName}
</dd>
</dl>
)}
<dl className="whitespace-no-wrap text-xs leading-5">{formatters.relativeDate(item.createdAt)}</dl>
</div>
</li>
{actions && (
<div className="flex items-center space-x-2">
<button type="button" onClick={actions.onThumbsUp} className="text-muted-foreground hover:text-foreground flex items-center space-x-2">
{userState?.hasThumbsUp ? <ThumbsUpIconFilled className="h-4 w-4" /> : <ThumbsUpIcon className="h-4 w-4" />}
</button>
<button type="button" onClick={actions.onThumbsDown} className="text-muted-foreground hover:text-foreground flex items-center space-x-2">
{userState?.hasThumbsDown ? <ThumbsDownIconFilled className="h-4 w-4" /> : <ThumbsDownIcon className="h-4 w-4" />}
</button>
</div>
)}
</div>
</div>
</div>
</div>
<div className="border-border border-b pt-6"></div>
<div className="grid gap-8 sm:grid-cols-12">
<div className="overflow-auto sm:col-span-9">
<div className="prose dark:prose-dark">
<div dangerouslySetInnerHTML={{ __html: renderSanitizedMarkdown(content) }} />
</div>
</div>
{item.relatedArticles.length > 0 && (
<div className="space-y-3 py-5 sm:col-span-3">
<div className="text-muted-foreground text-sm font-medium">{t("knowledgeBase.article.relatedArticles")}</div>
<div className="space-y-2">
{item.relatedArticles.map((x) => (
<Link to={x.href} key={x.href} className="flex items-center space-x-2 hover:underline">
<div className="text-sm">{x.title}</div>
</Link>
))}
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,38 @@
import { Link } from "react-router";
import RightIcon from "~/components/ui/icons/RightIcon";
import clsx from "clsx";
import ColorHoverUtils from "~/utils/shared/colors/ColorHoverUtils";
import type { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
export default function KbArticles({
kb,
items,
}: {
kb: KnowledgeBaseDto;
items: {
order: number;
title: string;
description: string;
href: string;
sectionId: string | null;
}[];
}) {
return (
<div className="rounded-md border border-border bg-background py-3">
{items.map((item) => {
return (
<div key={item.title} className={clsx("group", ColorHoverUtils.getBorder500(kb.color))}>
<Link to={item.href}>
<div className="flex items-center justify-between space-x-2 px-6 py-3 hover:bg-secondary">
<div className="">
<div className={clsx(" text-muted-foreground group-hover:text-foreground")}>{item.title}</div>
</div>
<RightIcon className={clsx("h-5 w-5 shrink-0 text-muted-foreground group-hover:text-foreground")} />
</div>
</Link>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,32 @@
import EmptyState from "~/components/ui/emptyState/EmptyState";
import type { KbCategoryDto } from "../../dtos/KbCategoryDto";
import type { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
import KbArticles from "./KbArticles";
import KnowledgeBaseUtils from "../../utils/KnowledgeBaseUtils";
import { useTranslation } from "react-i18next";
export default function KbArticlesBySection({ kb, item }: { kb: KnowledgeBaseDto; item: KbCategoryDto }) {
const { t } = useTranslation();
return (
<div>
{item.articles.length === 0 ? (
<EmptyState className="bg-background" captions={{ thereAreNo: t("knowledgeBase.article.empty") }} />
) : (
<div className="space-y-6">
{KnowledgeBaseUtils.getCategoryArticlesBySections({ kb, category: item }).map((item, idx) => {
return (
<div key={idx} className="space-y-3">
{item.section && (
<div className="flex items-center space-x-2">
<div className="text-xl font-bold">{item.section.title}</div>
</div>
)}
<KbArticles kb={kb} items={item.articles} />
</div>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,35 @@
import OrderListButtons from "~/components/ui/sort/OrderListButtons";
import TableSimple from "~/components/ui/tables/TableSimple";
import { useTranslation } from "react-i18next";
export default function KbSortArticles({
items,
}: {
items: {
id: string;
order: number;
title: string;
}[];
}) {
const { t } = useTranslation();
return (
<div className="space-y-1">
<label className="text-muted-foreground block text-xs font-medium">{t("knowledgeBase.authoring.articles.title")}</label>
<TableSimple
items={items}
headers={[
{
name: "order",
title: "",
value: (i, idx) => <OrderListButtons index={idx} items={items} editable={true} />,
},
{
name: "title",
title: t("shared.title"),
value: (item) => item.title,
},
]}
/>
</div>
);
}
@@ -0,0 +1,69 @@
import clsx from "clsx";
import { lazy, Suspense, useState } from "react";
import { useTranslation } from "react-i18next";
import InputText from "~/components/ui/input/InputText";
import { KnowledgeBaseArticleWithDetails } from "~/modules/knowledgeBase/db/kbArticles.db.server";
import useLocalStorage from "~/utils/hooks/use-local-storage";
const NovelEditor = lazy(() => import("~/modules/novel/ui/editor"));
export default function KbArticleForm({ item }: { item: KnowledgeBaseArticleWithDetails }) {
const { t } = useTranslation();
const [content, setContent] = useLocalStorage(item.id, item.contentDraft);
const [contentType, setContentType] = useState(item?.contentType ?? "wysiwyg");
return (
<div className="space-y-2">
<div className="grid gap-3">
<div className="space-y-2">
<div className="flex justify-between space-x-2">
{/* <label className="text-sm font-medium text-muted-foreground">Article</label> */}
<div className="flex items-center space-x-1">
<button type="button" onClick={() => setContentType("wysiwyg")} className="text-muted-foreground text-xs hover:underline">
<div className={clsx(contentType === "wysiwyg" ? "font-bold" : "")}>{t("knowledgeBase.authoring.editor.wysiwyg")}</div>
</button>
<div>•</div>
<button type="button" onClick={() => setContentType("markdown")} className="text-muted-foreground text-xs hover:underline">
<div className={clsx(contentType === "markdown" ? "font-bold" : "")}>{t("knowledgeBase.authoring.editor.markdown")}</div>
</button>
</div>
</div>
<input name="contentType" value={contentType} readOnly hidden />
{contentType === "wysiwyg" ? (
<div>
<input type="hidden" name="content" value={content} hidden readOnly />
<Suspense fallback={null}>
<NovelEditor
content={content}
onChange={(e) => {
if (e.text) {
setContent(e.html ?? "");
}
}}
/>
</Suspense>
{/* <InputText autoFocus name="content" placeholder="Content" value={content} setValue={setContent} /> */}
</div>
) : contentType === "markdown" ? (
<InputText
className="col-span-12"
rows={6}
editor="monaco"
editorLanguage="markdown"
editorTheme="vs-dark"
editorSize="screen"
editorFontSize={14}
name="content"
value={content}
setValue={(e) => {
if (e) {
setContent(e.toString());
}
}}
/>
) : null}
</div>
</div>
</div>
);
}
@@ -0,0 +1,193 @@
import { Form, useNavigation } from "react-router";
import { useEffect, useRef, useState } from "react";
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
import LoadingButton from "~/components/ui/buttons/LoadingButton";
import InputCheckboxWithDescription from "~/components/ui/input/InputCheckboxWithDescription";
import InputSelector from "~/components/ui/input/InputSelector";
import InputText, { RefInputText } from "~/components/ui/input/InputText";
import { KnowledgeBaseArticleSimple, KnowledgeBaseArticleWithDetails } from "~/modules/knowledgeBase/db/kbArticles.db.server";
import UrlUtils from "~/utils/app/UrlUtils";
import InputCombobox from "~/components/ui/input/InputCombobox";
import { useTranslation } from "react-i18next";
import InputSelect from "~/components/ui/input/InputSelect";
import KnowledgeBaseUtils from "../../utils/KnowledgeBaseUtils";
import { KnowledgeBaseCategorySimple } from "../../db/kbCategories.db.server";
export default function KbArticleSettingsForm({
allKnowledgeBases,
allArticles,
allCategories,
item,
onDelete,
}: {
allKnowledgeBases: { id: string; title: string }[];
allArticles: KnowledgeBaseArticleSimple[];
allCategories: KnowledgeBaseCategorySimple[];
item: KnowledgeBaseArticleWithDetails;
onDelete: () => void;
}) {
const { t } = useTranslation();
const navigation = useNavigation();
const inputTitle = useRef<RefInputText>(null);
const [knowledgeBaseId, setKnowledgeBaseId] = useState(item?.knowledgeBaseId ?? "");
const [categoryId, setCategoryId] = useState<string | undefined>(item?.categoryId ?? "");
const [category, setCategory] = useState<KnowledgeBaseCategorySimple | undefined>(undefined);
const [sectionId, setSectionId] = useState<string | undefined>(item?.sectionId ?? "");
const [language, setLanguage] = useState(item?.language ?? KnowledgeBaseUtils.defaultLanguage);
const [slug, setSlug] = useState(item?.slug ?? "");
const [title, setTitle] = useState(item?.title ?? "");
const [description, setDescription] = useState(item?.description ?? "");
const [seoImage, setSeoImage] = useState(item?.seoImage ?? "");
const [isFeatured, setIsFeatured] = useState(item?.featuredOrder ? true : false);
const [relatedArticles, setRelatedArticles] = useState(item.relatedArticles.map((f) => f.relatedArticleId) ?? []);
useEffect(() => {
if (!item) {
const slug = UrlUtils.slugify(title);
setSlug(slug);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [title]);
useEffect(() => {
setCategory(allCategories.find((f) => f.id === categoryId));
}, [allCategories, categoryId]);
return (
<Form method="post" className="space-y-6 pb-52">
<input name="action" value="edit" readOnly hidden />
<div className="space-y-3">
<div className="grid gap-3">
<InputText ref={inputTitle} name="title" title={t("knowledgeBase.adminArticles.fields.title")} value={title} setValue={setTitle} required />
<InputText
name="slug"
title={t("knowledgeBase.adminArticleSettings.fields.slug")}
value={slug}
setValue={setSlug}
required
hint={
<button type="button" className="text-muted-foreground" onClick={() => setSlug(UrlUtils.slugify(title))}>
{t("knowledgeBase.adminArticleSettings.actions.getFromTitle")}
</button>
}
/>
<InputText
name="description"
title={t("knowledgeBase.adminArticles.fields.description")}
value={description}
setValue={setDescription}
maxLength={300}
// required
/>
<InputSelector
name="knowledgeBaseId"
title={t("knowledgeBase.adminArticleSettings.fields.knowledgeBase")}
value={knowledgeBaseId}
setValue={(e) => setKnowledgeBaseId(e?.toString() ?? "")}
options={allKnowledgeBases.map((f) => {
return {
value: f.id,
name: f.title,
};
})}
/>
<InputSelect
name="language"
title={t("knowledgeBase.adminArticleSettings.fields.language")}
value={language}
setValue={(e) => setLanguage(e?.toString() || "")}
options={KnowledgeBaseUtils.getSupportedLanguageOptions(t)}
/>
<InputSelector
name="categoryId"
title={t("knowledgeBase.adminArticles.fields.category")}
value={categoryId}
setValue={(e) => setCategoryId(e?.toString())}
options={allCategories
.filter((f) => f.knowledgeBaseId === knowledgeBaseId && f.language === language)
.map((f) => {
return {
value: f.id,
name: f.title,
};
})}
/>
{category && category.sections.length > 0 && (
<InputSelector
name="sectionId"
title={t("knowledgeBase.adminArticleSettings.fields.section")}
value={sectionId}
setValue={(e) => setSectionId(e?.toString())}
options={category.sections.map((f) => {
return {
value: f.id,
name: f.title,
};
})}
/>
)}
<InputText name="seoImage" title={t("knowledgeBase.adminBaseForm.fields.seoImage")} value={seoImage} setValue={setSeoImage} />
{seoImage && (
<div>
<img className="xl:border-border overflow-hidden rounded-lg shadow-xl xl:border-b" src={seoImage} alt={title} />
</div>
)}
<InputCheckboxWithDescription
name="isFeatured"
title={t("knowledgeBase.adminArticles.fields.featured")}
value={isFeatured}
setValue={setIsFeatured}
description={t("knowledgeBase.adminArticleSettings.descriptions.featured")}
/>
<div>
{relatedArticles?.map((item, idx) => {
return <input key={idx} type="hidden" name={`relatedArticles[]`} value={item} />;
})}
<InputCombobox
name="relatedArticles"
title={t("knowledgeBase.adminArticleSettings.fields.relatedArticles")}
value={relatedArticles}
onChange={(e) => setRelatedArticles(e as string[])}
hint={
<button type="button" className="text-muted-foreground" onClick={() => setRelatedArticles([])}>
{t("shared.clear")}
</button>
}
minDisplayCount={1}
options={allArticles
.filter((f) => f.id !== item.id && f.knowledgeBaseId === knowledgeBaseId && f.language === language)
.map((f) => {
let title = f.title;
if (f.category) {
title = `${f.title} (${f.category.title})`;
}
if (f.category && f.section) {
title = `${f.title} (${f.category.title} / ${f.section.title})`;
}
return {
name: title,
value: f.id,
};
})}
/>
</div>
</div>
</div>
<div className="mt-5 flex justify-between space-x-2 sm:mt-6">
<div>
<ButtonSecondary disabled={navigation.state === "submitting"} type="button" className="w-full" onClick={onDelete} destructive>
<div>{t("shared.delete")}</div>
</ButtonSecondary>
</div>
<div className="flex space-x-2">
<LoadingButton actionName="edit" type="submit" disabled={navigation.state === "submitting"}>
{t("shared.save")}
</LoadingButton>
</div>
</div>
</Form>
);
}
@@ -0,0 +1,114 @@
import { Form, useNavigate, useNavigation } from "react-router";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
import LoadingButton from "~/components/ui/buttons/LoadingButton";
import InputText, { RefInputText } from "~/components/ui/input/InputText";
import UrlUtils from "~/utils/app/UrlUtils";
import { KnowledgeBaseDto } from "~/modules/knowledgeBase/dtos/KnowledgeBaseDto";
import EntityIcon from "~/components/layouts/icons/EntityIcon";
import { KnowledgeBaseCategoryWithDetails } from "../../helpers/KbCategoryModelHelper";
import KbSortArticles from "../articles/KbSortArticles";
import { adminPath } from "~/utils/app/CanonicalUrlUtils";
import QuestionMarkFilledIcon from "~/components/ui/icons/QuestionMarkFilledIcon";
export default function KbCategoryForm({
knowledgeBase,
language,
item,
onDelete,
}: {
knowledgeBase: KnowledgeBaseDto;
language: string;
item?: KnowledgeBaseCategoryWithDetails;
onDelete?: () => void;
}) {
const { t } = useTranslation();
const navigation = useNavigation();
const navigate = useNavigate();
// const [showFilterModal, setShowFilterModal] = useState<{ item?: { type: FeatureFlagsFilterType; value: string | null }; idx?: number }>();
const mainInput = useRef<RefInputText>(null);
useEffect(() => {
setTimeout(() => {
mainInput.current?.input.current?.focus();
}, 100);
}, []);
const [slug, setSlug] = useState(item?.slug || "");
const [title, setTitle] = useState(item?.title || "");
const [description, setDescription] = useState(item?.description || "");
const [icon, setIcon] = useState(
item?.icon || ""
// `<svg stroke="currentColor" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5"> <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" /> </svg>`
);
// const [language, setLanguage] = useState(item?.language || "");
const [seoImage, setSeoImage] = useState(item?.seoImage || "");
useEffect(() => {
if (!item) {
setSlug(UrlUtils.slugify(title.toLowerCase()));
}
}, [item, title]);
return (
<div>
<Form method="post" className="inline-block w-full overflow-hidden p-1 text-left align-bottom sm:align-middle">
<input type="hidden" name="action" value={item ? "edit" : "new"} hidden readOnly />
<div className="space-y-2">
<InputText ref={mainInput} autoFocus name="title" title={t("knowledgeBase.adminCategoryForm.fields.title")} value={title} setValue={setTitle} required />
<InputText name="slug" title={t("knowledgeBase.adminCategoryForm.fields.slug")} value={slug} setValue={setSlug} required />
<InputText name="description" title={t("knowledgeBase.adminCategoryForm.fields.description")} value={description} setValue={setDescription} />
<InputText
name="icon"
title={t("knowledgeBase.adminCategoryForm.fields.icon")}
value={icon}
setValue={(e) => setIcon(e.toString() ?? "")}
hint={t("knowledgeBase.adminCategoryForm.hints.icon")}
button={
<div className="absolute inset-y-0 right-0 flex py-0.5 pr-0.5 ">
<kbd className="border-border text-muted-foreground bg-secondary inline-flex w-10 items-center justify-center rounded border px-1 text-center font-sans text-xs font-medium">
{icon ? (
<EntityIcon className="text-muted-foreground h-7 w-7" icon={icon} title={title} />
) : (
<QuestionMarkFilledIcon className="text-muted-foreground h-5 w-5" aria-hidden="true" />
)}
</kbd>
</div>
}
/>
<InputText name="seoImage" title={t("knowledgeBase.adminCategoryForm.fields.seoImage")} value={seoImage} setValue={setSeoImage} hint={t("knowledgeBase.adminCategoryForm.hints.seoImage")} />
{seoImage && (
<div className="col-span-12">
<img className="xl:border-border overflow-hidden rounded-lg shadow-xl xl:border-b" src={seoImage} alt={title} />
</div>
)}
{item && <KbSortArticles items={item.articles.filter((f) => !f.sectionId).sort((a, b) => a.order - b.order)} />}
</div>
<div className="mt-5 flex justify-between space-x-2 sm:mt-6">
<div>
{onDelete && (
<ButtonSecondary
disabled={navigation.state === "submitting" || (item?.articles ?? []).length > 0}
type="button"
className="w-full"
onClick={onDelete}
destructive
>
<div>{t("shared.delete")}</div>
</ButtonSecondary>
)}
</div>
<div className="flex space-x-2">
<ButtonSecondary onClick={() => navigate(adminPath(`knowledge-base/bases/${knowledgeBase.slug}/categories/${language}`, { mode: "host-aware" }))}>{t("shared.cancel")}</ButtonSecondary>
<LoadingButton actionName={item ? "edit" : "new"} type="submit" disabled={navigation.state === "submitting"}>
{t("shared.save")}
</LoadingButton>
</div>
</div>
</Form>
</div>
);
}
@@ -0,0 +1,75 @@
import { Form, useNavigate, useNavigation } from "react-router";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
import LoadingButton from "~/components/ui/buttons/LoadingButton";
import InputText, { RefInputText } from "~/components/ui/input/InputText";
import { KnowledgeBaseDto } from "~/modules/knowledgeBase/dtos/KnowledgeBaseDto";
import { KnowledgeBaseCategorySectionWithDetails } from "~/modules/knowledgeBase/db/kbCategorySections.db.server";
import { KnowledgeBaseCategoryWithDetails } from "../../helpers/KbCategoryModelHelper";
import KbSortArticles from "../articles/KbSortArticles";
import { adminPath } from "~/utils/app/CanonicalUrlUtils";
export default function KbCategorySectionForm({
knowledgeBase,
category,
language,
item,
onDelete,
}: {
knowledgeBase: KnowledgeBaseDto;
category: KnowledgeBaseCategoryWithDetails;
language: string;
item?: KnowledgeBaseCategorySectionWithDetails;
onDelete?: () => void;
}) {
const { t } = useTranslation();
const navigation = useNavigation();
const navigate = useNavigate();
// const [showFilterModal, setShowFilterModal] = useState<{ item?: { type: FeatureFlagsFilterType; value: string | null }; idx?: number }>();
const mainInput = useRef<RefInputText>(null);
useEffect(() => {
setTimeout(() => {
mainInput.current?.input.current?.focus();
}, 100);
}, []);
const [title, setTitle] = useState(item?.title || "");
const [description, setDescription] = useState(item?.description || "");
return (
<div>
<Form method="post" className="inline-block w-full overflow-hidden p-1 text-left align-bottom sm:align-middle">
<input type="hidden" name="action" value={item ? "edit" : "new"} hidden readOnly />
<div className="space-y-2">
<InputText ref={mainInput} autoFocus name="title" title={t("knowledgeBase.adminCategorySectionForm.fields.title")} value={title} setValue={setTitle} required />
<InputText name="description" title={t("knowledgeBase.adminCategorySectionForm.fields.description")} value={description} setValue={setDescription} />
{item && <KbSortArticles items={item.articles.sort((a, b) => a.order - b.order)} />}
</div>
<div className="mt-5 flex justify-between space-x-2 sm:mt-6">
<div>
{onDelete && (
<ButtonSecondary
disabled={navigation.state === "submitting" || (item?.articles ?? []).length > 0}
type="button"
className="w-full"
onClick={onDelete}
destructive
>
<div>{t("shared.delete")}</div>
</ButtonSecondary>
)}
</div>
<div className="flex space-x-2">
<ButtonSecondary onClick={() => navigate(adminPath(`knowledge-base/bases/${knowledgeBase.slug}/categories/${language}`, { mode: "host-aware" }))}>{t("shared.cancel")}</ButtonSecondary>
<LoadingButton actionName={item ? "edit" : "new"} type="submit" disabled={navigation.state === "submitting"}>
{t("shared.save")}
</LoadingButton>
</div>
</div>
</Form>
</div>
);
}
@@ -0,0 +1,88 @@
import OrderIndexButtons from "~/components/ui/sort/OrderIndexButtons";
import TableSimple from "~/components/ui/tables/TableSimple";
import { KbNavLinkDto } from "~/modules/knowledgeBase/dtos/KbNavLinkDto";
import { useTranslation } from "react-i18next";
import { PlusIcon } from "~/components/ui/icons/vendor";
export default function KbNavLinksTable({
items,
setItems,
}: {
items: KbNavLinkDto[];
setItems: React.Dispatch<
React.SetStateAction<
{
name: string;
href: string;
order: number;
}[]
>
>;
}) {
const { t } = useTranslation();
return (
<div>
<div className="mb-1 flex items-center justify-between space-x-2 text-xs">
<label className="text-muted-foreground font-medium">{t("knowledgeBase.adminBaseForm.navLinks.title")}</label>
<button type="button" onClick={() => setItems([])} className="text-muted-foreground hover:text-foreground/80">
{t("shared.clear")}
</button>
</div>
<div className="">
<TableSimple
items={items.sort((a, b) => a.order - b.order)}
headers={[
{
name: "order",
title: "",
value: (item, idx) => (
<OrderIndexButtons
idx={idx}
items={items.map((f, idx) => {
return {
idx: idx,
order: f.order,
};
})}
onChange={(newItems) => {
setItems(
newItems.map((f, i) => {
return { ...items[i], order: f.order };
})
);
}}
/>
),
},
{
name: "title",
title: t("knowledgeBase.adminBaseForm.navLinks.columns.title"),
value: (item) => item.name,
editable: () => true,
setValue: (value, idx) => setItems((e) => e.map((item, i) => (i === idx ? { ...item, name: value } : item))),
},
{
name: "href",
title: t("knowledgeBase.adminBaseForm.navLinks.columns.link"),
value: (item) => item.href,
editable: () => true,
setValue: (value, idx) => setItems((e) => e.map((item, i) => (i === idx ? { ...item, href: value } : item))),
},
]}
/>
<button
type="button"
onClick={() => {
setItems([...items, { name: t("knowledgeBase.adminBaseForm.navLinks.defaultName", { index: items.length + 1 }), href: "/", order: items.length + 1 }]);
}}
className="border-border text-muted-foreground focus:text-foreground bg-background hover:bg-secondary/90 mt-2 flex items-center space-x-1 rounded-md border px-2 py-1 text-xs focus:ring-3 focus:ring-gray-300 focus:ring-offset-1"
>
<PlusIcon className="h-5 w-5" />
<span className="font-medium uppercase">{t("shared.add")}</span>
</button>
</div>
</div>
);
}
@@ -0,0 +1,215 @@
import { Form, useNavigate, useNavigation } from "react-router";
import { useEffect, useRef, useState } from "react";
import { Colors } from "~/application/enums/shared/Colors";
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
import LoadingButton from "~/components/ui/buttons/LoadingButton";
import InputCheckboxWithDescription from "~/components/ui/input/InputCheckboxWithDescription";
import InputCombobox from "~/components/ui/input/InputCombobox";
import InputSelect from "~/components/ui/input/InputSelect";
import InputSelector from "~/components/ui/input/InputSelector";
import InputText, { RefInputText } from "~/components/ui/input/InputText";
import { KnowledgeBaseDto } from "~/modules/knowledgeBase/dtos/KnowledgeBaseDto";
import UrlUtils from "~/utils/app/UrlUtils";
import { getColors } from "~/utils/shared/ColorUtils";
import KbNavLinksTable from "./KbNavLinksTable";
import KnowledgeBaseUtils from "~/modules/knowledgeBase/utils/KnowledgeBaseUtils";
import { logoLight, logoDark } from "~/assets/brand/default";
import clsx from "clsx";
import ColorBackgroundUtils from "~/utils/shared/colors/ColorBackgroundUtils";
import { useTranslation } from "react-i18next";
import { adminPath } from "~/utils/app/CanonicalUrlUtils";
export default function KnowledgeBaseForm({ item, onDelete }: { item?: KnowledgeBaseDto; onDelete?: () => void }) {
const { t } = useTranslation();
const navigation = useNavigation();
const navigate = useNavigate();
// const [showFilterModal, setShowFilterModal] = useState<{ item?: { type: FeatureFlagsFilterType; value: string | null }; idx?: number }>();
const mainInput = useRef<RefInputText>(null);
useEffect(() => {
setTimeout(() => {
mainInput.current?.input.current?.focus();
}, 100);
}, []);
const [basePath, setBasePath] = useState<string | number | undefined>(item ? item.basePath : "/");
const [slug, setSlug] = useState(item ? item.slug : "docs");
const [title, setTitle] = useState(item?.title || "");
const [description, setDescription] = useState(item?.description || "");
const [defaultLanguage, setDefaultLanguage] = useState<string | undefined>(item?.defaultLanguage || KnowledgeBaseUtils.defaultLanguage);
const [layout, setLayout] = useState<"list" | "articles" | "grid">(item?.layout || ("list" as any));
const [color, setColor] = useState(item?.color || Colors.BLUE);
const [enabled, setEnabled] = useState(item?.enabled ?? false);
// const [metatags, setMetatags] = useState<MetaTagsDto>(item?.metatags || []);
const [languages, setLanguages] = useState<string[]>(item?.languages || [KnowledgeBaseUtils.defaultLanguage]);
const [logo, setLogo] = useState<"light" | "dark" | string>(item?.logo || "dark");
const [seoImage, setSeoImage] = useState(item?.seoImage || "");
const [links, setLinks] = useState<{ name: string; href: string; order: number }[]>(item?.links || []);
useEffect(() => {
if (!item) {
setSlug(UrlUtils.slugify(title.toLowerCase()));
}
}, [item, title]);
return (
<div>
<Form method="post" className="inline-block w-full overflow-hidden p-1 text-left align-bottom sm:align-middle">
<input type="hidden" name="action" value={item ? "edit" : "new"} hidden readOnly />
{links.map((item, index) => {
return <input type="hidden" name="links[]" value={JSON.stringify(item)} key={index} hidden readOnly />;
})}
<div className="space-y-2">
<InputText ref={mainInput} autoFocus name="title" title={t("knowledgeBase.adminBaseForm.fields.title")} value={title} setValue={setTitle} required />
<InputSlug setBasePath={setBasePath} />
<InputText name="description" title={t("knowledgeBase.adminBaseForm.fields.description")} value={description} setValue={setDescription} />
<div>
{languages?.map((item, idx) => {
return <input key={idx} type="hidden" name={`languages[]`} value={item} />;
})}
<InputCombobox
name="languages"
title={t("knowledgeBase.adminBaseForm.fields.languages")}
value={languages}
onChange={(e) => setLanguages(e as string[])}
options={KnowledgeBaseUtils.getSupportedLanguageOptions(t)}
/>
</div>
<InputSelect
name="defaultLanguage"
title={t("knowledgeBase.adminBaseForm.fields.defaultLanguage")}
value={defaultLanguage}
setValue={(e) => setDefaultLanguage(e?.toString() || "en")}
options={languages.map((item) => {
return {
value: item,
name: KnowledgeBaseUtils.getLanguageName(item, t) || item,
};
})}
/>
<InputSelect
name="layout"
title={t("knowledgeBase.adminBaseForm.fields.layout")}
value={layout}
setValue={(e) => setLayout((e?.toString() as any) || "list")}
options={[
{
value: "list",
name: t("knowledgeBase.layouts.list"),
},
{
value: "articles",
name: t("knowledgeBase.layouts.articles"),
},
{
value: "grid",
name: t("knowledgeBase.layouts.grid"),
},
{
value: "docs",
name: t("knowledgeBase.layouts.docs"),
},
]}
/>
<InputSelector
name="color"
title={t("knowledgeBase.adminBaseForm.fields.color")}
value={color}
setValue={(e) => setColor(e as number)}
withColors={true}
options={
getColors().map((color) => {
return {
name: t(`app.shared.colors.${Colors[color ?? 0]}`),
value: color,
color,
};
}) ?? []
}
/>
<InputText
name="logo"
title={t("knowledgeBase.adminBaseForm.fields.logo")}
value={logo}
setValue={setLogo}
hint={t("knowledgeBase.adminBaseForm.hints.logo")}
button={
<div className="absolute inset-y-0 right-0 flex py-0.5 pr-0.5 ">
<kbd
className={clsx(
"border-border text-muted-foreground inline-flex w-auto items-center justify-center rounded border px-1 text-center font-sans text-xs font-medium",
ColorBackgroundUtils.getBg700(color)
)}
>
{logo === "light" ? (
<img className="h-7 w-auto" src={logoLight} alt={t("knowledgeBase.adminBaseForm.preview.logoAlt")} />
) : logo === "dark" ? (
<img className="h-7 w-auto" src={logoDark} alt={t("knowledgeBase.adminBaseForm.preview.logoAlt")} />
) : logo.startsWith("http") ? (
<img className="h-7 w-auto" src={logo} alt={t("knowledgeBase.adminBaseForm.preview.logoAlt")} />
) : (
<div className="italic text-white">{t("knowledgeBase.adminBaseForm.preview.invalidLogo")}</div>
)}
</kbd>
</div>
}
/>
<InputText name="seoImage" title={t("knowledgeBase.adminBaseForm.fields.seoImage")} value={seoImage} setValue={setSeoImage} hint={t("knowledgeBase.adminBaseForm.hints.url")} />
{seoImage && (
<div className="col-span-12">
<img className="xl:border-border overflow-hidden rounded-lg shadow-xl xl:border-b" src={seoImage} alt={title} />
</div>
)}
<InputCheckboxWithDescription
name="enabled"
title={t("knowledgeBase.adminBaseForm.fields.enabled")}
value={enabled}
setValue={setEnabled}
description={t("knowledgeBase.adminBaseForm.descriptions.enabled")}
/>
<KbNavLinksTable items={links} setItems={setLinks} />
</div>
<div className="mt-5 flex justify-between space-x-2 sm:mt-6">
<div>
{onDelete && (
<ButtonSecondary disabled={navigation.state === "submitting"} type="button" className="w-full" onClick={onDelete} destructive>
<div>{t("shared.delete")}</div>
</ButtonSecondary>
)}
</div>
<div className="flex space-x-2">
<ButtonSecondary onClick={() => navigate(adminPath("knowledge-base/bases", { mode: "host-aware" }))}>{t("shared.cancel")}</ButtonSecondary>
<LoadingButton actionName={item ? "edit" : "new"} type="submit" disabled={navigation.state === "submitting"}>
{t("shared.save")}
</LoadingButton>
</div>
</div>
</Form>
</div>
);
}
function InputSlug({
setBasePath,
}: {
setBasePath: React.Dispatch<React.SetStateAction<string | number | undefined>>;
}) {
const { t } = useTranslation();
useEffect(() => {
setBasePath("/");
}, [setBasePath]);
return (
<div className="space-y-2">
<input type="hidden" name="basePath" value={"/"} hidden readOnly />
<input type="hidden" name="slug" value={"docs"} hidden readOnly />
<InputText title={t("knowledgeBase.adminBaseForm.fields.url")} value={UrlUtils.join("/", "docs")} setValue={() => {}} disabled />
</div>
);
}
@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs";
import path from "node:path";
const repoRoot = process.cwd();
const sourceFiles = [
"apps/web/app/modules/knowledgeBase/components/bases/KnowledgeBaseForm.tsx",
"apps/web/app/modules/knowledgeBase/components/bases/KbCategoryForm.tsx",
"apps/web/app/modules/knowledgeBase/components/bases/KbCategorySectionForm.tsx",
"apps/web/app/modules/knowledgeBase/components/bases/KbArticleSettingsForm.tsx",
"apps/web/app/modules/knowledgeBase/components/bases/KbNavLinksTable.tsx",
"apps/web/app/modules/knowledgeBase/components/categories/KbCategoriesGrid.tsx",
"apps/web/app/modules/knowledgeBase/components/categories/KbCategoriesList.tsx",
"apps/web/app/modules/knowledgeBase/components/templates/PreviewKbsTemplate.tsx",
"apps/web/app/routes/admin/knowledge-base.tsx",
"apps/web/app/routes/admin/knowledge-base/index.tsx",
"apps/web/app/routes/admin/knowledge-base/bases.tsx",
"apps/web/app/routes/admin/knowledge-base/bases.import.tsx",
"apps/web/app/routes/admin/knowledge-base/danger.tsx",
"apps/web/app/routes/admin/knowledge-base/articles.tsx",
"apps/web/app/routes/admin/knowledge-base/bases.$slug/categories.tsx",
"apps/web/app/routes/admin/knowledge-base/bases.$slug/categories.$lang.tsx",
"apps/web/app/routes/admin/knowledge-base/bases.$slug/articles.tsx",
"apps/web/app/routes/admin/knowledge-base/bases.$slug/articles.$lang.tsx",
"apps/web/app/routes/admin/knowledge-base/bases.$slug/articles.$lang.$id.tsx",
"apps/web/app/routes/admin/knowledge-base/bases.$slug/articles.$lang.$id.edit.tsx",
];
describe("knowledge base authoring i18n", () => {
it("uses semantic keys for visible admin authoring labels", () => {
const sources = sourceFiles.map((file) => fs.readFileSync(path.join(repoRoot, file), "utf8")).join("\n");
expect(sources).toContain("knowledgeBase.adminBaseForm.fields.title");
expect(sources).toContain("knowledgeBase.adminCategoryForm.fields.title");
expect(sources).toContain("knowledgeBase.adminCategorySectionForm.fields.description");
expect(sources).toContain("knowledgeBase.adminCategories.confirmDelete.category.title");
expect(sources).toContain("knowledgeBase.adminCategories.counts.articles");
expect(sources).toContain("knowledgeBase.adminCategories.counts.sections");
expect(sources).toContain("formatters.number(item.sections.length)");
expect(sources).toContain("formatters.number(category.articles.filter((f) => f.sectionId === item.id).length)");
expect(sources).toContain("knowledgeBase.adminBaseForm.navLinks.defaultName");
expect(sources).toContain("knowledgeBase.adminArticleSettings.fields.relatedArticles");
expect(sources).toContain("knowledgeBase.adminArticleDetail.actions.publish");
expect(sources).toContain("knowledgeBase.adminBases.metaTitle");
expect(sources).toContain("knowledgeBase.adminBases.actions.categories");
expect(sources).toContain("knowledgeBase.adminBases.counts.articles");
expect(sources).toContain("formattedCount: formatters.number(i.count.articles)");
expect(sources).toContain("value: (i) => formatters.number(i.count.views)");
expect(sources).toContain("knowledgeBase.adminBases.empty.title");
expect(sources).toContain("knowledgeBase.adminImport.title");
expect(sources).toContain("knowledgeBase.adminImport.configuration");
expect(sources).toContain("knowledgeBase.adminDanger.resetAllData");
expect(sources).toContain("knowledgeBase.adminDanger.resetAllDataDescription");
expect(sources).toContain("knowledgeBase.authoring.metaTitle");
expect(sources).toContain("knowledgeBase.authoring.navigation.overview");
expect(sources).toContain("knowledgeBase.authoring.breadcrumbs.knowledgeBases");
expect(sources).toContain("knowledgeBase.authoring.categories.count");
expect(sources).toContain("knowledgeBase.authoring.articles.count");
expect(sources).toContain("knowledgeBase.category.articleCount");
expect(sources).toContain("knowledgeBase.authoring.actions.saveDraft");
expect(sources).toContain("knowledgeBase.authoring.articleSettings.title");
expect(sources).toContain("knowledgeBase.templates.preview.headings.knowledgeBases");
expect(sources).toContain("knowledgeBase.templates.preview.columns.knowledgeBase");
expect(sources).toContain("KnowledgeBaseUtils.getSupportedLanguageOptions(t)");
expect(sources).toContain("KnowledgeBaseUtils.getLanguageName(item, t)");
expect(sources).toContain("KnowledgeBaseUtils.getLanguageName(f, t)");
expect(sources).toContain("KnowledgeBaseUtils.getLanguageName(params.lang!, t)");
expect(sources).toContain("KnowledgeBaseUtils.getLanguageName(language, t)");
expect(sources).toContain("QuestionMarkFilledIcon");
expect(sources).not.toContain("ui.autogenerated");
expect(sources).not.toContain("KnowledgeBaseUtils.getLanguageName(f)</div>");
expect(sources).not.toContain("KnowledgeBaseUtils.getLanguageName(f)}");
expect(sources).not.toContain("KnowledgeBaseUtils.getLanguageName(params.lang!)");
expect(sources).not.toContain("KnowledgeBaseUtils.getLanguageName(language))");
expect(sources).not.toContain('text-red-600">?</span>');
expect(sources).not.toContain("adminCategories.counts.sections\", { count: item.sections.length })");
expect(sources).not.toContain("adminCategories.counts.articles\", { count: category.articles.filter((f) => f.sectionId === item.id).length })");
});
it("keeps the remaining authoring strings out of JSX literals", () => {
const forbidden = [
'{"Title"}',
'{"Layout"}',
'{"Color"}',
'{"Logo"}',
'{"Enabled"}',
'{"Delete"}',
'{"Cancel"}',
'{"Save"}',
'{"Language"}',
'{"Category"}',
'{"Section"}',
'{"Featured"}',
'{"Nav links"}',
'{"Clear"}',
'{"Add"}',
">Settings<",
">Preview<",
">Publish<",
">Import<",
">Danger<",
">article<",
">articles<",
"Reset all Knowledge Base data?",
"Delete all Knowledge Base data",
"Get started by creating a new knowledge base.",
];
for (const file of sourceFiles) {
const source = fs.readFileSync(path.join(repoRoot, file), "utf8");
for (const literal of forbidden) {
expect(source).not.toContain(literal);
}
}
});
});
@@ -0,0 +1,37 @@
import type { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
import type { KbCategoryDto } from "../../dtos/KbCategoryDto";
import KbCategoriesList from "./KbCategoriesList";
import KbCategoriesGrid from "./KbCategoriesGrid";
import KbCategoriesTopArticles from "./KbCategoriesTopArticles";
import EmptyState from "~/components/ui/emptyState/EmptyState";
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
export default function KbCategories({ items, kb }: { items: KbCategoryDto[]; kb: KnowledgeBaseDto }) {
const { t } = useTranslation();
return (
<div>
{items.length === 0 ? (
<div className="space-y-4">
<h2 className="text-2xl font-bold">{t("knowledgeBase.category.plural")}</h2>
<EmptyState className="bg-background" captions={{ thereAreNo: t("knowledgeBase.category.empty") }} />
</div>
) : (
<Fragment>
{kb.layout === "list" ? (
<KbCategoriesList kb={kb} items={items} />
) : kb.layout === "articles" ? (
<KbCategoriesTopArticles kb={kb} items={items} />
) : kb.layout === "grid" ? (
<KbCategoriesGrid kb={kb} items={items} columns={3} />
) : kb.layout === "docs" ? (
<div className="space-y-4">
<h2 className="text-2xl font-bold">{t("knowledgeBase.category.plural")}</h2>
<KbCategoriesGrid kb={kb} items={items} columns={3} />
</div>
) : null}
</Fragment>
)}
</div>
);
}
@@ -0,0 +1,67 @@
import { Link } from "react-router";
import clsx from "clsx";
import type { KbCategoryDto } from "~/modules/knowledgeBase/dtos/KbCategoryDto";
import { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
import ColorTextUtils from "~/utils/shared/colors/ColorTextUtils";
import ColorGroupHoverUtils from "~/utils/shared/colors/ColorGroupHoverUtils";
import { useTranslation } from "react-i18next";
import { useLocaleFormatters } from "~/locale/format";
import { sanitizeIconSvg } from "~/utils/html-sanitizer";
export default function KbCategoriesGrid({ kb, items, columns }: { kb: KnowledgeBaseDto; items: KbCategoryDto[]; columns: number }) {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
return (
<div
className={clsx(
"grid grid-cols-1 gap-4",
columns === 2 && "md:grid-cols-2",
columns === 3 && "md:grid-cols-3",
columns === 4 && "md:grid-cols-4",
columns === 5 && "md:grid-cols-5",
columns === 6 && "md:grid-cols-6"
)}
>
{items.map((item) => {
return (
<div key={item.title} className="border-border bg-background hover:bg-secondary hover:border-secondary-foreground group rounded-md border">
<Link to={item.href} className="w-full">
<div className="flex flex-col space-y-3 p-6">
<div className="shrink-0">
{item.icon.startsWith("<svg") ? (
<div
dangerouslySetInnerHTML={{
__html: sanitizeIconSvg(
item.icon,
clsx("h-8 w-8", ColorTextUtils.getText500(kb.color), ColorGroupHoverUtils.getText700(kb.color))
),
}}
/>
) : item.icon.includes("http") ? (
<img className="h-8 w-8" src={item.icon} alt={item.title} />
) : (
<></>
)}
</div>
<div className="flex items-center space-x-8">
<div className="flex w-full flex-col">
<div className="font-bold">{item.title}</div>
<div className="text-muted-foreground mt-1 text-sm">{item.description}</div>
<div className="mt-6 flex items-center justify-between space-x-2">
<div className="flex items-center space-x-2">
<div className="text-sm">
{t("knowledgeBase.category.articleCount", { count: item.articles.length, formattedCount: formatters.number(item.articles.length) })}
</div>
</div>
{/* <div className="text-sm text-muted-foreground">{item.version}</div> */}
</div>
</div>
</div>
</div>
</Link>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,60 @@
import { Link } from "react-router";
import clsx from "clsx";
import type { KbCategoryDto } from "~/modules/knowledgeBase/dtos/KbCategoryDto";
import ColorTextUtils from "~/utils/shared/colors/ColorTextUtils";
import { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
import ColorGroupHoverUtils from "~/utils/shared/colors/ColorGroupHoverUtils";
import { useTranslation } from "react-i18next";
import { useLocaleFormatters } from "~/locale/format";
export default function KbCategoriesList({ kb, items }: { kb: KnowledgeBaseDto; items: KbCategoryDto[] }) {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
return (
<div className="space-y-4">
<h2 className="text-2xl font-bold">{t("knowledgeBase.category.plural")}</h2>
<div className={clsx("grid grid-cols-1 gap-4")}>
{items.map((item) => {
return (
<div key={item.title} className="border-border bg-background hover:bg-secondary hover:border-secondary-foreground group rounded-md border">
<Link to={item.href} className="w-full">
<div className="flex items-center space-x-8 p-6">
<div className="shrink-0">
{item.icon.startsWith("<svg") ? (
<div
dangerouslySetInnerHTML={{
__html:
item.icon.replace(
"<svg",
`<svg class='${clsx("h-12 w-12", ColorTextUtils.getText500(kb.color), ColorGroupHoverUtils.getText700(kb.color))}'`
) ?? "",
}}
/>
) : item.icon.includes("http") ? (
<img className="h-12 w-12" src={item.icon} alt={item.title} />
) : (
<></>
)}
</div>
<div className="flex w-full flex-col">
<div className="font-bold">{item.title}</div>
<div className="text-muted-foreground mt-1 text-sm">{item.description}</div>
<div className="mt-6 flex items-center justify-between space-x-2">
<div className="flex items-center space-x-2">
<div className="text-sm">
{t("knowledgeBase.category.articleCount", { count: item.articles.length, formattedCount: formatters.number(item.articles.length) })}
</div>
</div>
{/* <div className="text-sm text-muted-foreground">{item.version}</div> */}
</div>
</div>
</div>
</Link>
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,63 @@
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import type { KbCategoryDto } from "../../dtos/KbCategoryDto";
import { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
import ColorTextUtils from "~/utils/shared/colors/ColorTextUtils";
import ColorGroupHoverUtils from "~/utils/shared/colors/ColorGroupHoverUtils";
export default function KbCategoriesTopArticles({ kb, items }: { kb: KnowledgeBaseDto; items: KbCategoryDto[] }) {
const { t } = useTranslation();
function getTopArticles(item: KbCategoryDto) {
return item.articles.slice(0, 3);
}
return (
<div className="space-y-4">
{items.map((item) => {
return (
<div key={item.slug} className="space-y-4">
<div className="flex items-baseline justify-between space-x-2">
<div className="flex items-center space-x-4">
{item.icon.startsWith("<svg") ? (
<div
dangerouslySetInnerHTML={{
__html:
item.icon.replace(
"<svg",
`<svg class='${clsx("h-6 w-6", ColorTextUtils.getText500(kb.color), ColorGroupHoverUtils.getText700(kb.color))}'`
) ?? "",
}}
/>
) : item.icon.includes("http") ? (
<img className="h-6 w-6" src={item.icon} alt={item.title} />
) : null}
<Link to={item.href} className="hover:underline">
<h2 className="text-2xl font-bold">{item.title}</h2>
</Link>
</div>
<Link to={item.href} className="text-muted-foreground text-sm hover:underline">
{t("knowledgeBase.category.viewAll")}
</Link>
</div>
<div className={clsx("grid grid-cols-3 gap-4")}>
{getTopArticles(item).map((item) => {
return (
<div key={item.title} className="border-border bg-background hover:bg-secondary hover:border-secondary-foreground group rounded-md border">
<Link to={item.href} className="w-full">
<div className="flex items-center space-x-8 p-6">
<div className="flex w-full flex-col space-y-1">
<div className="group-hover:text-foreground/80 font-medium">{item.title}</div>
<div className="text-muted-foreground text-sm">{item.description}</div>
</div>
</div>
</Link>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,96 @@
import type { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
import type { KbCategoryDto } from "../../dtos/KbCategoryDto";
import BreadcrumbSimple from "~/components/ui/breadcrumbs/BreadcrumbSimple";
import clsx from "clsx";
import { Link, useParams } from "react-router";
import KbArticlesBySection from "../articles/KbArticlesBySection";
import KnowledgeBaseUtils from "../../utils/KnowledgeBaseUtils";
import ColorTextUtils from "~/utils/shared/colors/ColorTextUtils";
import ColorGroupHoverUtils from "~/utils/shared/colors/ColorGroupHoverUtils";
import { useTranslation } from "react-i18next";
import { sanitizeIconSvg } from "~/utils/html-sanitizer";
export default function KbCategory({ kb, item, allCategories }: { kb: KnowledgeBaseDto; item: KbCategoryDto; allCategories: KbCategoryDto[] }) {
const { t } = useTranslation();
const params = useParams();
return (
<div className="space-y-6">
<BreadcrumbSimple
menu={[
{
title: kb.title,
routePath: KnowledgeBaseUtils.getKbUrl({ kb, params }),
},
{
title: item.title ?? "",
routePath: item.href,
},
]}
/>
<div className="flex flex-col space-y-6">
<div className="flex items-center space-x-8">
<div className="flex w-full flex-col">
<div className="flex items-center space-x-3">
{item.icon && (
<div className="shrink-0">
{item.icon.startsWith("<svg") ? (
<div
dangerouslySetInnerHTML={{
__html: sanitizeIconSvg(
item.icon,
clsx("h-7 w-7", ColorTextUtils.getText500(kb.color), ColorGroupHoverUtils.getText700(kb.color))
),
}}
/>
) : item.icon.includes("http") ? (
<img className="h-7 w-7" src={item.icon} alt={item.title} />
) : (
<></>
)}
</div>
)}
<div className="text-xl font-bold md:text-2xl">{item.title}</div>
</div>
<div className="text-muted-foreground mt-2 font-normal">{item.description}</div>
{/* <div className="mt-6 flex items-center space-x-2 justify-between">
<div className="flex items-center space-x-2">
<div className="text-sm">
{item.articles.length} <span>{item.articles.length === 1 ? "article" : "articles"}</span>
</div>
</div>
<div className="text-sm text-muted-foreground">{item.version}</div>
</div> */}
</div>
</div>
</div>
<div className="border-border border-b"></div>
<div className="grid grid-cols-12 gap-8">
<div className="col-span-3 hidden space-y-3 md:block">
<div className="text font-bold">{t("knowledgeBase.category.plural")}</div>
<div className="space-y-1">
{allCategories.map((category) => {
return (
<Link to={category.href} key={category.title}>
<div
className={clsx(
"group rounded-md border border-transparent p-2",
category.slug === item.slug ? "bg-secondary font-bold" : "hover:bg-secondary"
)}
>
<div className="text-sm">{category.title}</div>
</div>
</Link>
);
})}
</div>
</div>
<div className="col-span-12 pt-2 md:col-span-9">
<KbArticlesBySection kb={kb} item={item} />
</div>
</div>
</div>
);
}
@@ -0,0 +1,72 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const repoRoot = process.cwd();
const sourceFiles = [
"apps/web/app/modules/knowledgeBase/components/KbHeader.tsx",
"apps/web/app/modules/knowledgeBase/components/KbFeaturedArticles.tsx",
"apps/web/app/modules/knowledgeBase/components/articles/KbArticlesBySection.tsx",
"apps/web/app/modules/knowledgeBase/components/articles/KbArticleContent.tsx",
"apps/web/app/modules/knowledgeBase/components/categories/KbCategoriesGrid.tsx",
"apps/web/app/modules/knowledgeBase/components/categories/KbCategoriesList.tsx",
"apps/web/app/modules/knowledgeBase/components/layouts/KbDocsHeader.tsx",
];
const publicRouteFiles = [
"apps/web/app/modules/knowledgeBase/routes/api/KbRoutes.Article.Api.tsx",
"apps/web/app/modules/knowledgeBase/routes/views/KbRoutes.Article.View.tsx",
"apps/web/app/modules/knowledgeBase/routes/views/KbRoutes.Category.View.tsx",
];
describe("knowledge base public i18n", () => {
it("keeps public article empty states and dates locale-aware", () => {
const sources = sourceFiles.map((file) => fs.readFileSync(path.join(repoRoot, file), "utf8")).join("\n");
expect(sources).toContain("knowledgeBase.article.emptyFeatured");
expect(sources).toContain("knowledgeBase.article.empty");
expect(sources).toContain("knowledgeBase.header.logoAlt");
expect(sources).toContain("useLocaleFormatters");
expect(sources).not.toContain('alt="Logo"');
expect(sources).not.toContain('"No featured articles"');
expect(sources).not.toContain('"No articles"');
expect(sources).not.toContain("DateUtils.dateAgo");
expect(sources).not.toContain("ui.autogenerated");
});
it("keeps public category article counts locale-formatted", () => {
const sources = [
"apps/web/app/modules/knowledgeBase/components/categories/KbCategoriesGrid.tsx",
"apps/web/app/modules/knowledgeBase/components/categories/KbCategoriesList.tsx",
]
.map((file) => fs.readFileSync(path.join(repoRoot, file), "utf8"))
.join("\n");
const en = JSON.parse(fs.readFileSync(path.join(repoRoot, "apps/web/public/locales/en/translations.json"), "utf8"));
const de = JSON.parse(fs.readFileSync(path.join(repoRoot, "apps/web/public/locales/de/translations.json"), "utf8"));
expect(sources).toContain("useLocaleFormatters");
expect(sources).toContain("formattedCount: formatters.number(item.articles.length)");
expect(sources).not.toContain("{ count: item.articles.length })");
expect(en.knowledgeBase.category.articleCount_one).toContain("{{formattedCount}}");
expect(en.knowledgeBase.category.articleCount_other).toContain("{{formattedCount}}");
expect(de.knowledgeBase.category.articleCount_one).toContain("{{formattedCount}}");
expect(de.knowledgeBase.category.articleCount_other).toContain("{{formattedCount}}");
});
it("keeps public route errors on public translation keys", () => {
const sources = publicRouteFiles.map((file) => fs.readFileSync(path.join(repoRoot, file), "utf8")).join("\n");
const en = JSON.parse(fs.readFileSync(path.join(repoRoot, "apps/web/public/locales/en/translations.json"), "utf8"));
const de = JSON.parse(fs.readFileSync(path.join(repoRoot, "apps/web/public/locales/de/translations.json"), "utf8"));
expect(sources).toContain("knowledgeBase.public.errors.articleNotFound");
expect(sources).toContain("knowledgeBase.public.errors.categoryNotFound");
expect(sources).toContain("knowledgeBase.public.errors.invalidAction");
expect(sources).not.toContain("knowledgeBase.admin.errors.notFound");
expect(sources).not.toContain("knowledgeBase.admin.errors.invalidAction");
expect(en.knowledgeBase.public.errors.articleNotFound).toBeTruthy();
expect(en.knowledgeBase.public.errors.categoryNotFound).toBeTruthy();
expect(en.knowledgeBase.public.errors.invalidAction).toBeTruthy();
expect(de.knowledgeBase.public.errors.articleNotFound).toBeTruthy();
expect(de.knowledgeBase.public.errors.categoryNotFound).toBeTruthy();
expect(de.knowledgeBase.public.errors.invalidAction).toBeTruthy();
});
});
@@ -0,0 +1,103 @@
import { Link, useNavigate, useParams, useSearchParams } from "react-router";
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import ColorBackgroundUtils from "~/utils/shared/colors/ColorBackgroundUtils";
import ColorTextUtils from "~/utils/shared/colors/ColorTextUtils";
import ColorFocusUtils from "~/utils/shared/colors/ColorFocusUtils";
import ColorRingUtils from "~/utils/shared/colors/ColorRingUtils";
import ColorGradientUtils from "~/utils/shared/colors/ColorGradientUtils";
import { logoLight, logoDark } from "~/assets/brand/default";
import KnowledgeBaseUtils from "../../utils/KnowledgeBaseUtils";
import { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
interface Props {
kb: KnowledgeBaseDto;
}
const withTitleAndDescription = true;
export default function KbDocsHeader({ kb }: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const params = useParams();
const [searchParams] = useSearchParams();
const logoAlt = t("knowledgeBase.header.logoAlt", { title: kb.title });
return (
<div>
<div className={clsx("bg-linear-to-r text-white", ColorGradientUtils.getFrom800To900(kb.color))}>
<div className="mx-auto max-w-(--breakpoint-2xl) space-y-8 px-8 py-4">
<div className="flex items-center justify-between space-x-2">
<Link to={KnowledgeBaseUtils.getKbUrl({ kb, params })} className="flex select-none items-center space-x-2">
{kb.logo === "light" ? (
<img className="h-8 w-auto" src={logoLight} alt={logoAlt} />
) : kb.logo === "dark" ? (
<img className="h-8 w-auto" src={logoDark} alt={logoAlt} />
) : (
<img className="h-8 w-auto" src={kb.logo} alt={logoAlt} />
)}
{!withTitleAndDescription && (
<Fragment>
<span className={clsx("hidden px-2 sm:block", ColorTextUtils.getText300(kb.color))}>|</span>
<span className="hidden font-semibold sm:block">{kb.title}</span>
</Fragment>
)}
</Link>
<div className="flex items-center space-x-4">
{kb.links.map((link) => {
return (
<a key={link.href} href={link.href} className={clsx("transition-colors duration-150 hover:text-white", ColorTextUtils.getText300(kb.color))}>
{link.name}
</a>
);
})}
<div className="flex items-center space-x-2">
{kb.languages.length > 1 && (
<select
id="lang"
name="lang"
defaultValue={params.lang || kb.defaultLanguage}
className={clsx(
"block w-full rounded-md border-0 py-1.5 pl-3 pr-10 text-white ring-1 ring-inset focus:ring-2 sm:text-sm sm:leading-6",
ColorBackgroundUtils.getBg700(kb.color),
ColorRingUtils.getRing800(kb.color),
ColorFocusUtils.getRing600(kb.color)
)}
onChange={(e) => {
navigate(
`${KnowledgeBaseUtils.getKbUrl({
kb,
params: {
lang: e.target.value,
},
})}?${searchParams.toString()}`
);
}}
>
{kb.languages.map((lang) => {
const language = KnowledgeBaseUtils.supportedLanguages.find((f) => f.value === lang);
if (!language) return null;
return (
<Fragment key={lang}>
<option key={lang} value={lang}>
{KnowledgeBaseUtils.getLanguageName(language.value, t)}
</option>
</Fragment>
);
})}
</select>
)}
</div>
</div>
</div>
{/* {withTitleAndDescription && (
<div className="space-y-4">
<h1 className="text-2xl font-semibold tracking-tight sm:text-4xl">{kb.title}</h1>
<p className={clsx("text-base", ColorTextUtils.getText300(kb.color))}>{kb.description}</p>
</div>
)} */}
{/* {withSearch && <KbSearch kb={kb} autoFocus={withTitleAndDescription} />} */}
</div>
</div>
</div>
);
}
@@ -0,0 +1,174 @@
import { useLocation, useSubmit } from "react-router";
import { useTranslation } from "react-i18next";
import { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
import { KbCategoryDto } from "../../dtos/KbCategoryDto";
import clsx from "clsx";
import { MobileNav } from "~/components/ui/docs/DocsMobileNav";
import { Fragment } from "react";
import RightIcon from "~/components/ui/icons/RightIcon";
import { ScrollArea } from "~/components/ui/scroll-area";
import { SideBarItem } from "~/application/sidebar/SidebarItem";
import { KbArticleDto } from "../../dtos/KbArticleDto";
import KbArticle from "../articles/KbArticle";
import KnowledgeBaseUtils from "../../utils/KnowledgeBaseUtils";
import KbDocsHeader from "./KbDocsHeader";
import KbFeaturedArticles from "../KbFeaturedArticles";
import KbCategories from "../categories/KbCategories";
import { DocsSidebarNav } from "~/components/ui/docs/DocsSidebarNav";
export default function KbDocsLayout({
kb,
categories,
featured,
article,
}: {
kb: KnowledgeBaseDto;
categories: KbCategoryDto[];
featured?: KbArticleDto[];
article?: {
article: KbArticleDto;
category: KbCategoryDto;
userState: {
hasThumbsUp: boolean;
hasThumbsDown: boolean;
};
} | null;
}) {
// const outlet = useOutlet();
const location = useLocation();
const { t } = useTranslation();
const items: SideBarItem[] = [];
categories.forEach((category) => {
const sections = KnowledgeBaseUtils.getCategoryArticlesBySections({ kb, category });
items.push({
title: category.title,
path: category.href,
items: [{ title: t("shared.overview"), path: category.href }],
});
let categoryItem = items[items.length - 1];
sections.forEach((section) => {
if (section.articles.length === 0) return;
if (section.section) {
items.push({
title: section.section.title,
path: "",
items: [],
});
categoryItem = items[items.length - 1];
}
section.articles.forEach((article) => {
categoryItem.items?.push({
title: article.title,
path: article.href,
});
});
});
});
const currentItem = () => {
const findRecursive = (items: SideBarItem[]): SideBarItem | undefined => {
for (const item of items) {
if (item.path && item.title && item.path === location.pathname) return item;
if (item.items) {
const found = findRecursive(item.items);
if (found) return found;
}
}
};
return findRecursive(items);
};
return (
<div>
<KbDocsHeader kb={kb} />
<div className="mx-auto max-w-(--breakpoint-2xl) flex-1 items-start px-4 pt-3 sm:px-6 md:grid md:grid-cols-[220px_minmax(0,1fr)] md:gap-6 lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-10">
<nav className={clsx("not-prose border-border mb-2 flex items-center truncate border-b pb-2 md:hidden")}>
<ol className="flex items-center space-x-2">
<MobileNav items={items} />
{currentItem() && (
<Fragment>
<RightIcon className="text-muted-foreground h-4 w-4 shrink-0" />
<div>
<h3 className="text-muted-foreground text-base">{currentItem()?.title}</h3>
</div>
</Fragment>
)}
</ol>
</nav>
<aside className="fixed top-14 z-30 -ml-2 hidden h-[calc(100vh-90px)] w-full shrink-0 md:sticky md:block">
<ScrollArea className="h-full pb-6 pr-6 lg:pb-8">
<DocsSidebarNav items={items} />
</ScrollArea>
</aside>
{article ? (
<Article kb={kb} article={article} />
) : (
<div>
<div className="mx-auto max-w-5xl px-2 py-6 sm:px-8">
<div className="space-y-8">
{featured && featured.length > 0 && <KbFeaturedArticles kb={kb} items={featured} />}
<KbCategories kb={kb} items={categories} />
</div>
</div>
</div>
)}
</div>
</div>
);
}
function Article({
kb,
article,
}: {
kb: KnowledgeBaseDto;
article: {
article: KbArticleDto;
category: KbCategoryDto;
userState: {
hasThumbsUp: boolean;
hasThumbsDown: boolean;
};
};
}) {
const submit = useSubmit();
function onAction(name: string) {
const form = new FormData();
form.set("action", name);
submit(form, {
method: "post",
});
}
return (
<div className="px-2 py-2 sm:px-8 sm:py-8">
<div className="space-y-3">
<KbArticle
kb={kb}
category={article.category}
item={article.article}
userState={{
hasThumbsUp: article.userState.hasThumbsUp,
hasThumbsDown: article.userState.hasThumbsDown,
}}
actions={{
onThumbsUp: () => onAction("thumbsUp"),
onThumbsDown: () => onAction("thumbsDown"),
}}
withMenu={false}
/>
</div>
</div>
);
}
// function getArticles({ kb, category, sectionId }: { kb: KnowledgeBaseDto; category: KbCategoryDto; sectionId: string | null }) {
// return category.articles
// .filter((f) => f.publishedAt !== null && (sectionId ? f.sectionId === sectionId : true))
// .sort((a, b) => a.order - b.order)
// .map((article) => ({
// title: article.title,
// path: KnowledgeBaseUtils.getArticleUrl({ kb, article, params: {} }),
// }));
// }
@@ -0,0 +1,71 @@
import TableSimple from "~/components/ui/tables/TableSimple";
import { KnowledgeBasesTemplateDto } from "../../dtos/KnowledgeBasesTemplateDto";
import { t as i18nT } from "i18next";
export default function PreviewKbsTemplate({ template }: { template: KnowledgeBasesTemplateDto }) {
return (
<div className="space-y-2">
<h3 className="font-medium">{i18nT("knowledgeBase.templates.preview.headings.knowledgeBases")}</h3>
<TableSimple
items={template.knowledgeBases}
headers={[
{ name: "slug", title: i18nT("knowledgeBase.templates.preview.columns.slug"), value: (i) => i.slug },
{
name: "title",
title: i18nT("knowledgeBase.templates.preview.columns.title"),
value: (i) => (
<div className="flex-col">
<div>{i.title}</div>
<div className="text-muted-foreground text-xs">{i.description}</div>
</div>
),
},
{ name: "enabled", title: i18nT("knowledgeBase.templates.preview.columns.enabled"), value: (i) => i.enabled },
]}
/>
<h3 className="font-medium">{i18nT("knowledgeBase.templates.preview.headings.categories")}</h3>
<TableSimple
items={template.categories}
headers={[
{ name: "slug", title: i18nT("knowledgeBase.templates.preview.columns.slug"), value: (i) => i.slug },
{
name: "title",
title: i18nT("knowledgeBase.templates.preview.columns.title"),
value: (i) => (
<div className="flex-col">
<div>{i.title}</div>
<div className="text-muted-foreground text-xs">{i.description}</div>
</div>
),
},
{
name: "sections",
title: i18nT("knowledgeBase.templates.preview.columns.sections"),
value: (i) => i.sections.map((s) => s.title).join(", "),
},
{ name: "knowledgeBase", title: i18nT("knowledgeBase.templates.preview.columns.knowledgeBase"), value: (i) => i.knowledgeBaseSlug },
]}
/>
<h3 className="font-medium">{i18nT("knowledgeBase.templates.preview.headings.articles")}</h3>
<TableSimple
items={template.articles}
headers={[
{ name: "slug", title: i18nT("knowledgeBase.templates.preview.columns.slug"), value: (i) => i.slug },
{
name: "title",
title: i18nT("knowledgeBase.templates.preview.columns.title"),
value: (i) => (
<div className="flex-col">
<div>{i.title}</div>
<div className="text-muted-foreground text-xs">{i.description}</div>
</div>
),
},
{ name: "knowledgeBase", title: i18nT("knowledgeBase.templates.preview.columns.knowledgeBase"), value: (i) => i.knowledgeBaseSlug },
]}
/>
</div>
);
}
@@ -0,0 +1,163 @@
import { db, dbRead } from "~/utils/db.server";
export async function createKnowledgeBaseView({ userAnalyticsId, knowledgeBaseId }: { userAnalyticsId: string | null; knowledgeBaseId: string }) {
if (!userAnalyticsId) {
return;
}
const existing = await db.knowledgeBaseViews.findUnique({
where: {
knowledgeBaseId_userAnalyticsId: {
userAnalyticsId,
knowledgeBaseId,
},
},
});
if (!existing) {
await db.knowledgeBaseViews.create({
data: {
userAnalyticsId,
knowledgeBaseId,
},
});
}
}
export async function createKnowledgeBaseArticleView({ userAnalyticsId, articleId }: { userAnalyticsId: string | null; articleId: string }) {
if (!userAnalyticsId) {
return;
}
const existing = await db.knowledgeBaseArticleViews.findUnique({
where: {
knowledgeBaseArticleId_userAnalyticsId: {
userAnalyticsId,
knowledgeBaseArticleId: articleId,
},
},
});
if (!existing) {
await db.knowledgeBaseArticleViews.create({
data: {
userAnalyticsId,
knowledgeBaseArticleId: articleId,
},
});
}
}
export async function voteArticle({ userAnalyticsId, articleId, type }: { userAnalyticsId: string | null; articleId: string; type: "up" | "down" }) {
if (!userAnalyticsId) {
return;
}
if (type === "up") {
await db.knowledgeBaseArticleDownvotes.deleteMany({
where: {
userAnalyticsId,
knowledgeBaseArticleId: articleId,
},
});
const existing = await db.knowledgeBaseArticleUpvotes.findUnique({
where: {
knowledgeBaseArticleId_userAnalyticsId: {
userAnalyticsId,
knowledgeBaseArticleId: articleId,
},
},
});
if (!existing) {
await db.knowledgeBaseArticleUpvotes.create({
data: {
userAnalyticsId,
knowledgeBaseArticleId: articleId,
},
});
} else {
await db.knowledgeBaseArticleUpvotes.delete({
where: {
knowledgeBaseArticleId_userAnalyticsId: {
userAnalyticsId,
knowledgeBaseArticleId: articleId,
},
},
});
}
} else {
await db.knowledgeBaseArticleUpvotes.deleteMany({
where: {
userAnalyticsId,
knowledgeBaseArticleId: articleId,
},
});
const existing = await db.knowledgeBaseArticleDownvotes.findUnique({
where: {
knowledgeBaseArticleId_userAnalyticsId: {
userAnalyticsId,
knowledgeBaseArticleId: articleId,
},
},
});
if (!existing) {
await db.knowledgeBaseArticleDownvotes.create({
data: {
userAnalyticsId,
knowledgeBaseArticleId: articleId,
},
});
} else {
await db.knowledgeBaseArticleDownvotes.delete({
where: {
knowledgeBaseArticleId_userAnalyticsId: {
userAnalyticsId,
knowledgeBaseArticleId: articleId,
},
},
});
}
}
}
export async function getArticleStateByUserAnalyticsId({ userAnalyticsId, articleId }: { userAnalyticsId: string | null; articleId: string }) {
if (!userAnalyticsId) {
return {
hasThumbsUp: false,
hasThumbsDown: false,
};
}
return {
hasThumbsUp: !!(await db.knowledgeBaseArticleUpvotes.findUnique({
where: {
knowledgeBaseArticleId_userAnalyticsId: {
userAnalyticsId,
knowledgeBaseArticleId: articleId,
},
},
})),
hasThumbsDown: !!(await db.knowledgeBaseArticleDownvotes.findUnique({
where: {
knowledgeBaseArticleId_userAnalyticsId: {
userAnalyticsId,
knowledgeBaseArticleId: articleId,
},
},
})),
};
}
export async function countAllKbsViews() {
// Dashboard count — read-only, replica-safe.
return await dbRead.knowledgeBaseViews.count();
}
export async function countAllKbsArticleViews() {
// Dashboard count — read-only, replica-safe.
return await dbRead.knowledgeBaseArticleViews.count();
}
export async function countAllKbsArticleUpvotes() {
// Dashboard count — read-only, replica-safe.
return await dbRead.knowledgeBaseArticleUpvotes.count();
}
export async function countAllKbsArticleDownvotes() {
// Dashboard count — read-only, replica-safe.
return await dbRead.knowledgeBaseArticleDownvotes.count();
}
@@ -0,0 +1,634 @@
import { KnowledgeBaseArticle, KnowledgeBaseRelatedArticle, Prisma } from "@toolctrl/db/prisma-browser";
import { PaginationDto } from "~/application/dtos/data/PaginationDto";
import { cachified, clearCacheKey } from "~/utils/cache.server";
import { db, dbRead } from "~/utils/db.server";
export type KnowledgeBaseArticleWithDetails = KnowledgeBaseArticle & {
knowledgeBase: { id: string; slug: string; title: string };
category: { slug: string; title: string } | null;
section: { order: number; title: string } | null;
relatedArticles: (KnowledgeBaseRelatedArticle & {
relatedArticle: { id: string; order: number; title: string; slug: string };
})[];
createdByUser: {
email: string;
firstName: string;
lastName: string;
avatar: string | null;
} | null;
_count: {
views: number;
upvotes: number;
downvotes: number;
};
};
export type KnowledgeBaseArticleSimple = {
id: string;
knowledgeBaseId: string;
title: string;
language: string;
category: { id: string; slug: string; title: string } | null;
section: { id: string; order: number; title: string } | null;
};
const include = {
knowledgeBase: {
select: { slug: true, title: true, id: true },
},
category: {
select: { slug: true, title: true },
},
section: {
select: { order: true, title: true },
},
relatedArticles: {
include: {
relatedArticle: {
select: { id: true, order: true, title: true, slug: true },
},
},
},
createdByUser: {
select: { email: true, firstName: true, lastName: true, avatar: true },
},
_count: {
select: { views: true, upvotes: true, downvotes: true },
},
};
export async function getAllKnowledgeBaseArticles({
knowledgeBaseSlug,
categoryId,
language,
}: {
knowledgeBaseSlug: string;
categoryId?: string;
language: string | undefined;
}): Promise<KnowledgeBaseArticleWithDetails[]> {
// Public KB listing — eventually-consistent read is fine.
return await dbRead.knowledgeBaseArticle.findMany({
where: {
knowledgeBase: { slug: knowledgeBaseSlug },
categoryId,
language,
},
include,
orderBy: [
{
category: { order: "asc" },
},
{
section: { order: "asc" },
},
{
order: "asc",
},
],
});
}
export async function getKnowledgeBaseArticlesForTemplateExport({
knowledgeBaseIds,
}: {
knowledgeBaseIds: string[];
}): Promise<KnowledgeBaseArticleWithDetails[]> {
if (knowledgeBaseIds.length === 0) {
return [];
}
return await dbRead.knowledgeBaseArticle.findMany({
where: {
knowledgeBaseId: { in: knowledgeBaseIds },
},
include,
orderBy: [
{
knowledgeBaseId: "asc",
},
{
category: { order: "asc" },
},
{
section: { order: "asc" },
},
{
order: "asc",
},
],
});
}
export async function getAllKnowledgeBaseArticlesSimple(): Promise<KnowledgeBaseArticleSimple[]> {
// Admin sidebar enumeration — read-only, replica-safe.
return await dbRead.knowledgeBaseArticle.findMany({
select: {
id: true,
knowledgeBaseId: true,
title: true,
language: true,
category: { select: { id: true, slug: true, title: true } },
section: { select: { id: true, order: true, title: true } },
},
orderBy: [
{
knowledgeBaseId: "asc",
},
{
category: { order: "asc" },
},
{
section: { order: "asc" },
},
{
order: "asc",
},
],
});
}
export async function getKnowledgeBaseArticlesSimple({
knowledgeBaseId,
language,
}: {
knowledgeBaseId: string;
language: string;
}): Promise<KnowledgeBaseArticleSimple[]> {
// Per-KB simple listing — read-only.
return await dbRead.knowledgeBaseArticle.findMany({
where: { knowledgeBaseId, language },
select: {
id: true,
knowledgeBaseId: true,
title: true,
language: true,
category: { select: { id: true, slug: true, title: true } },
section: { select: { id: true, order: true, title: true } },
},
orderBy: [{ category: { order: "asc" } }, { section: { order: "asc" } }, { order: "asc" }],
});
}
export async function getAllKnowledgeBaseArticlesWithPagination({
knowledgeBaseSlug,
language,
pagination,
filters,
}: {
knowledgeBaseSlug: string | undefined;
language: string | undefined;
pagination: {
page: number;
pageSize: number;
};
filters: {
title?: string;
description?: string;
categoryId?: string | null;
content?: string;
};
}): Promise<{
items: KnowledgeBaseArticleWithDetails[];
pagination: PaginationDto;
}> {
const where: Prisma.KnowledgeBaseArticleWhereInput = {
knowledgeBase: { slug: knowledgeBaseSlug },
language,
};
if (filters.title !== undefined) {
where.title = { contains: filters.title, mode: "insensitive" };
}
if (filters.description !== undefined) {
where.description = { contains: filters.description, mode: "insensitive" };
}
if (filters.categoryId !== undefined) {
where.categoryId = filters.categoryId;
}
if (filters.content !== undefined) {
where.contentPublishedAsText = { contains: filters.content, mode: "insensitive" };
}
// Paginated listing for admin grid — read-only, replica-safe.
const items = await dbRead.knowledgeBaseArticle.findMany({
take: pagination.pageSize,
skip: (pagination.page - 1) * pagination.pageSize,
where,
include,
orderBy: [{ updatedAt: "desc" }, { createdAt: "desc" }],
});
const totalItems = await dbRead.knowledgeBaseArticle.count({
where,
});
return {
items,
pagination: {
page: pagination.page,
pageSize: pagination.pageSize,
totalItems,
totalPages: Math.ceil(totalItems / pagination.pageSize),
},
};
}
export async function findKnowledgeBaseArticles({
knowledgeBaseSlug,
language,
query,
}: {
knowledgeBaseSlug: string;
language: string | undefined;
query: string;
}): Promise<KnowledgeBaseArticleWithDetails[]> {
// Public KB search — read-only, replica-safe.
return await dbRead.knowledgeBaseArticle.findMany({
where: {
knowledgeBase: { slug: knowledgeBaseSlug },
language,
publishedAt: { not: null },
OR: [
{ title: { contains: query, mode: "insensitive" } },
{ description: { contains: query, mode: "insensitive" } },
{
contentPublishedAsText: { contains: query, mode: "insensitive" },
},
],
},
include,
orderBy: [
{
category: { order: "asc" },
},
{
section: { order: "asc" },
},
{
order: "asc",
},
],
});
}
export async function findKnowledgeBaseArticlesByIds({
knowledgeBaseSlug,
language,
ids,
}: {
knowledgeBaseSlug: string;
language: string | undefined;
ids: string[];
}): Promise<KnowledgeBaseArticleWithDetails[]> {
if (ids.length === 0) return [];
// Public KB search hydration — read-only, replica-safe.
return await dbRead.knowledgeBaseArticle.findMany({
where: {
id: { in: ids },
knowledgeBase: { slug: knowledgeBaseSlug },
language,
publishedAt: { not: null },
},
include,
});
}
export async function getFeaturedKnowledgeBaseArticles({
knowledgeBaseId,
language,
}: {
knowledgeBaseId: string;
language: string;
}): Promise<KnowledgeBaseArticleWithDetails[]> {
// Featured-article enumeration for public KB landing — read-only.
return await dbRead.knowledgeBaseArticle.findMany({
where: {
knowledgeBaseId,
language,
featuredOrder: { not: null },
publishedAt: { not: null },
},
include,
orderBy: [{ featuredOrder: "asc" }],
});
}
export async function getKbArticleById(id: string): Promise<KnowledgeBaseArticleWithDetails | null> {
// Read-only lookup; admin edit views read this then save via a separate
// request through the write client.
return await dbRead.knowledgeBaseArticle.findUnique({
where: { id },
include,
});
}
/**
* Returns true only when the article exists AND its parent knowledge base is
* owned by the given tenant. Used to scope resource access (e.g. realtime-collab
* token issuance) so a token can never be minted for a global or other-tenant
* article id via a tenant context.
*/
export async function knowledgeBaseArticleBelongsToTenant(articleId: string, tenantId: string): Promise<boolean> {
const article = await dbRead.knowledgeBaseArticle.findUnique({
where: { id: articleId },
select: { knowledgeBase: { select: { tenantId: true } } },
});
return article?.knowledgeBase?.tenantId === tenantId;
}
export async function getKbArticleByIdInKnowledgeBase({
id,
knowledgeBaseId,
language,
}: {
id: string;
knowledgeBaseId: string;
language: string;
}): Promise<KnowledgeBaseArticleWithDetails | null> {
// Read-only lookup, replica-safe.
return await dbRead.knowledgeBaseArticle.findFirst({
where: { id, knowledgeBaseId, language },
include,
});
}
export async function getKbArticleBySlug({
knowledgeBaseId,
slug,
language,
}: {
knowledgeBaseId: string;
slug: string;
language: string;
}): Promise<KnowledgeBaseArticleWithDetails | null> {
return await cachified({
key: `kb-article:${knowledgeBaseId}:${slug}:${language}`,
ttl: 60 * 60 * 24,
getFreshValue: () =>
// Public KB article fetch (cached for 24h) — replica-safe; lag is
// well under the cache TTL.
dbRead.knowledgeBaseArticle
.findFirstOrThrow({
where: {
knowledgeBaseId,
slug,
language,
},
include: {
...include,
},
})
.catch(() => {
return null;
}),
});
}
export async function createKnowledgeBaseArticle(data: {
knowledgeBaseId: string;
categoryId: string | null;
sectionId: string | null;
slug: string;
title: string;
description: string;
order: number;
contentDraft: string;
contentPublished: string;
contentPublishedAsText: string;
contentType: string;
language: string;
featuredOrder: number | null;
createdByUserId: string | null;
seoImage: string;
publishedAt: Date | null;
}) {
const article = await db.knowledgeBaseArticle.create({
data: {
knowledgeBaseId: data.knowledgeBaseId,
categoryId: data.categoryId,
sectionId: data.sectionId,
slug: data.slug,
title: data.title,
description: data.description,
order: data.order,
contentDraft: data.contentDraft,
contentPublished: data.contentPublished,
contentPublishedAsText: data.contentPublishedAsText,
contentType: data.contentType,
language: data.language,
featuredOrder: data.featuredOrder,
createdByUserId: data.createdByUserId,
seoImage: data.seoImage,
publishedAt: data.publishedAt,
},
});
return article;
}
export async function updateKnowledgeBaseArticle(
id: string,
data: {
categoryId?: string | null;
sectionId?: string | null;
slug?: string;
title?: string;
description?: string;
order?: number;
contentDraft?: string;
contentPublished?: string;
contentPublishedAsText?: string;
contentType?: string;
language?: string;
featuredOrder?: number | null;
createdByUserId?: string | null;
seoImage?: string;
publishedAt?: Date | null;
relatedArticles?: string[];
}
) {
const update: Prisma.KnowledgeBaseArticleUncheckedUpdateInput = {
categoryId: data.categoryId,
sectionId: data.sectionId,
slug: data.slug,
title: data.title,
description: data.description,
order: data.order,
contentDraft: data.contentDraft,
contentPublished: data.contentPublished,
contentPublishedAsText: data.contentPublishedAsText,
contentType: data.contentType,
language: data.language,
featuredOrder: data.featuredOrder,
createdByUserId: data.createdByUserId,
seoImage: data.seoImage,
publishedAt: data.publishedAt,
};
const updated = await db.knowledgeBaseArticle
.update({
where: { id },
data: update,
})
.then((item) => {
clearCacheKey(`kb-article:${item.knowledgeBaseId}:${item.slug}:${item.language}`);
return item;
});
if (data.relatedArticles) {
await db.knowledgeBaseRelatedArticle.deleteMany({
where: {
articleId: id,
},
});
await db.knowledgeBaseRelatedArticle.createMany({
data: data.relatedArticles.map((f) => {
return {
articleId: id,
relatedArticleId: f,
};
}),
});
}
}
export async function updateKnowledgeBaseArticleInKnowledgeBase(
{
id,
knowledgeBaseId,
language,
}: {
id: string;
knowledgeBaseId: string;
language: string;
},
data: {
categoryId?: string | null;
sectionId?: string | null;
slug?: string;
title?: string;
description?: string;
order?: number;
contentDraft?: string;
contentPublished?: string;
contentPublishedAsText?: string;
contentType?: string;
language?: string;
featuredOrder?: number | null;
createdByUserId?: string | null;
seoImage?: string;
publishedAt?: Date | null;
relatedArticles?: string[];
}
) {
const update: Prisma.KnowledgeBaseArticleUncheckedUpdateInput = {
categoryId: data.categoryId,
sectionId: data.sectionId,
slug: data.slug,
title: data.title,
description: data.description,
order: data.order,
contentDraft: data.contentDraft,
contentPublished: data.contentPublished,
contentPublishedAsText: data.contentPublishedAsText,
contentType: data.contentType,
language: data.language,
featuredOrder: data.featuredOrder,
createdByUserId: data.createdByUserId,
seoImage: data.seoImage,
publishedAt: data.publishedAt,
};
const result = await db.knowledgeBaseArticle.updateMany({
where: { id, knowledgeBaseId, language },
data: update,
});
if (result.count !== 1) {
return result;
}
const item = await db.knowledgeBaseArticle.findUnique({ where: { id }, select: { knowledgeBaseId: true, slug: true, language: true } });
if (item) {
clearCacheKey(`kb-article:${item.knowledgeBaseId}:${item.slug}:${item.language}`);
}
if (data.relatedArticles) {
await db.knowledgeBaseRelatedArticle.deleteMany({
where: {
articleId: id,
article: { knowledgeBaseId, language },
},
});
await db.knowledgeBaseRelatedArticle.createMany({
data: data.relatedArticles.map((f) => {
return {
articleId: id,
relatedArticleId: f,
};
}),
});
}
return result;
}
export async function updateKnowledgeBaseArticleOrderInCategory({ id, categoryId, order }: { id: string; categoryId: string; order: number }) {
return await db.knowledgeBaseArticle.updateMany({
where: { id, categoryId },
data: { order },
});
}
export async function updateKnowledgeBaseArticleOrderInSection({ id, sectionId, order }: { id: string; sectionId: string; order: number }) {
return await db.knowledgeBaseArticle.updateMany({
where: { id, sectionId },
data: { order },
});
}
export async function updateKnowledgeBaseArticleOrderInKnowledgeBase({
id,
knowledgeBaseId,
language,
order,
}: {
id: string;
knowledgeBaseId: string;
language: string;
order: number;
}) {
return await db.knowledgeBaseArticle.updateMany({
where: { id, knowledgeBaseId, language },
data: { order },
});
}
export async function deleteKnowledgeBaseArticle(id: string) {
const result = await db.knowledgeBaseArticle
.delete({
where: { id },
})
.then((item) => {
clearCacheKey(`kb-article:${item.knowledgeBaseId}:${item.slug}:${item.language}`);
return item;
});
return result;
}
export async function deleteKnowledgeBaseArticleInKnowledgeBase({
id,
knowledgeBaseId,
language,
}: {
id: string;
knowledgeBaseId: string;
language: string;
}) {
const item = await db.knowledgeBaseArticle.findFirst({
where: { id, knowledgeBaseId, language },
select: { id: true, slug: true },
});
if (!item) {
return { count: 0 };
}
const result = await db.knowledgeBaseArticle.deleteMany({
where: { id, knowledgeBaseId, language },
});
if (result.count === 1) {
clearCacheKey(`kb-article:${knowledgeBaseId}:${item.slug}:${language}`);
}
return result;
}
export async function countKnowledgeBaseArticles() {
// Dashboard count — read-only, replica-safe.
return await dbRead.knowledgeBaseArticle.count();
}
@@ -0,0 +1,241 @@
import { db, dbRead } from "~/utils/db.server";
import { KnowledgeBaseCategoryWithDetails } from "../helpers/KbCategoryModelHelper";
import { Prisma } from "@toolctrl/db/prisma-browser";
export type KnowledgeBaseCategorySimple = {
id: string;
knowledgeBaseId: string;
title: string;
language: string;
sections: { id: string; order: number; title: string }[];
};
const include = {
articles: {
select: { id: true, order: true, title: true, description: true, slug: true, language: true, sectionId: true, publishedAt: true },
},
sections: {
select: { id: true, order: true, title: true, description: true },
},
};
export async function getAllKnowledgeBaseCategories({
knowledgeBaseSlug,
language,
}: {
knowledgeBaseSlug: string | undefined;
language: string | undefined;
}): Promise<KnowledgeBaseCategoryWithDetails[]> {
// Public KB category list — read-only, replica-safe.
return await dbRead.knowledgeBaseCategory.findMany({
where: {
knowledgeBase: { slug: knowledgeBaseSlug },
language,
},
include,
orderBy: { order: "asc" },
});
}
export async function getKnowledgeBaseCategoriesForTemplateExport({
knowledgeBaseIds,
}: {
knowledgeBaseIds: string[];
}): Promise<KnowledgeBaseCategoryWithDetails[]> {
if (knowledgeBaseIds.length === 0) {
return [];
}
return await dbRead.knowledgeBaseCategory.findMany({
where: {
knowledgeBaseId: { in: knowledgeBaseIds },
},
include,
orderBy: [{ knowledgeBaseId: "asc" }, { order: "asc" }],
});
}
export async function getAllKnowledgeBaseCategoriesSimple(): Promise<KnowledgeBaseCategorySimple[]> {
// Admin sidebar enumeration — read-only.
return await dbRead.knowledgeBaseCategory.findMany({
select: {
id: true,
knowledgeBaseId: true,
title: true,
language: true,
sections: { select: { id: true, order: true, title: true } },
},
orderBy: [{ knowledgeBaseId: "asc" }, { order: "asc" }],
});
}
export async function getKbCategoryById(id: string): Promise<KnowledgeBaseCategoryWithDetails | null> {
// Read-only lookup.
return await dbRead.knowledgeBaseCategory.findUnique({
where: { id },
include,
});
}
export async function getKbCategoryByIdInKnowledgeBase({
id,
knowledgeBaseId,
language,
}: {
id: string;
knowledgeBaseId: string;
language: string;
}): Promise<KnowledgeBaseCategoryWithDetails | null> {
// Read-only lookup.
return await dbRead.knowledgeBaseCategory.findFirst({
where: { id, knowledgeBaseId, language },
include,
});
}
export async function getKbCategoryBySlug({
knowledgeBaseId,
slug,
language,
}: {
knowledgeBaseId: string;
slug: string;
language: string | undefined;
}): Promise<KnowledgeBaseCategoryWithDetails | null> {
const where: Prisma.KnowledgeBaseCategoryWhereInput = {
knowledgeBaseId,
slug,
language,
};
// Public route lookup, read-only.
return await dbRead.knowledgeBaseCategory.findFirst({
where,
include,
});
}
export async function createKnowledgeBaseCategory(data: {
knowledgeBaseId: string;
slug: string;
order: number;
title: string;
description: string;
icon: string;
language: string;
seoImage: string;
}): Promise<KnowledgeBaseCategoryWithDetails> {
return await db.knowledgeBaseCategory.create({
data: {
knowledgeBaseId: data.knowledgeBaseId,
slug: data.slug,
order: data.order,
title: data.title,
description: data.description,
icon: data.icon,
language: data.language,
seoImage: data.seoImage,
},
include,
});
}
export async function updateKnowledgeBaseCategory(
id: string,
data: {
slug?: string;
title?: string;
order?: number;
description?: string;
icon?: string;
language?: string;
seoImage?: string;
}
) {
return await db.knowledgeBaseCategory.update({
where: { id },
data: {
slug: data.slug,
order: data.order,
title: data.title,
description: data.description,
icon: data.icon,
language: data.language,
seoImage: data.seoImage,
},
});
}
export async function updateKnowledgeBaseCategoryInKnowledgeBase(
{
id,
knowledgeBaseId,
language,
}: {
id: string;
knowledgeBaseId: string;
language: string;
},
data: {
slug?: string;
title?: string;
order?: number;
description?: string;
icon?: string;
language?: string;
seoImage?: string;
}
) {
return await db.knowledgeBaseCategory.updateMany({
where: { id, knowledgeBaseId, language },
data: {
slug: data.slug,
order: data.order,
title: data.title,
description: data.description,
icon: data.icon,
language: data.language,
seoImage: data.seoImage,
},
});
}
export async function updateKnowledgeBaseCategoryOrderInKnowledgeBase({
id,
knowledgeBaseId,
language,
order,
}: {
id: string;
knowledgeBaseId: string;
language: string;
order: number;
}) {
return await db.knowledgeBaseCategory.updateMany({
where: { id, knowledgeBaseId, language },
data: { order },
});
}
export async function deleteKnowledgeBaseCategory(id: string) {
return await db.knowledgeBaseCategory.delete({
where: { id },
});
}
export async function deleteKnowledgeBaseCategoryInKnowledgeBase({
id,
knowledgeBaseId,
language,
}: {
id: string;
knowledgeBaseId: string;
language: string;
}) {
return await db.knowledgeBaseCategory.deleteMany({
where: { id, knowledgeBaseId, language },
});
}
export async function countKnowledgeBaseCategories() {
// Dashboard count — read-only.
return await dbRead.knowledgeBaseCategory.count();
}
@@ -0,0 +1,121 @@
import { KnowledgeBaseCategorySection } from "@toolctrl/db/prisma-browser";
import { db, dbRead } from "~/utils/db.server";
export type KnowledgeBaseCategorySectionWithDetails = KnowledgeBaseCategorySection & {
articles: {
id: string;
order: number;
title: string;
}[];
};
export async function getAllKnowledgeBaseCategorySections(): Promise<KnowledgeBaseCategorySectionWithDetails[]> {
// Read-only listing used by public KB rendering — replica is fine.
return await dbRead.knowledgeBaseCategorySection.findMany({
include: {
articles: { select: { id: true, order: true, title: true } },
},
});
}
export async function getKbCategorySectionById(id: string): Promise<KnowledgeBaseCategorySectionWithDetails | null> {
// Read-only lookup; called from both admin edit views and public rendering.
return await dbRead.knowledgeBaseCategorySection.findUnique({
where: { id },
include: {
articles: { select: { id: true, order: true, title: true } },
},
});
}
export async function getKbCategorySectionByIdInCategory({
id,
categoryId,
}: {
id: string;
categoryId: string;
}): Promise<KnowledgeBaseCategorySectionWithDetails | null> {
// Read-only lookup, replica-safe.
return await dbRead.knowledgeBaseCategorySection.findFirst({
where: { id, categoryId },
include: {
articles: { select: { id: true, order: true, title: true } },
},
});
}
export async function createKnowledgeBaseCategorySection(data: { categoryId: string; order: number; title: string; description: string }) {
return await db.knowledgeBaseCategorySection.create({
data: {
categoryId: data.categoryId,
order: data.order,
title: data.title,
description: data.description,
},
});
}
export async function updateKnowledgeBaseCategorySection(
id: string,
data: {
order?: number;
title?: string;
description?: string;
}
) {
return await db.knowledgeBaseCategorySection.update({
where: { id },
data: {
order: data.order,
title: data.title,
description: data.description,
},
});
}
export async function updateKnowledgeBaseCategorySectionInCategory(
{ id, categoryId }: { id: string; categoryId: string },
data: {
order?: number;
title?: string;
description?: string;
}
) {
return await db.knowledgeBaseCategorySection.updateMany({
where: { id, categoryId },
data: {
order: data.order,
title: data.title,
description: data.description,
},
});
}
export async function updateKnowledgeBaseCategorySectionOrderInKnowledgeBase({
id,
knowledgeBaseId,
language,
order,
}: {
id: string;
knowledgeBaseId: string;
language: string;
order: number;
}) {
return await db.knowledgeBaseCategorySection.updateMany({
where: { id, category: { knowledgeBaseId, language } },
data: { order },
});
}
export async function deleteKnowledgeBaseCategorySection(id: string) {
return await db.knowledgeBaseCategorySection.delete({
where: { id },
});
}
export async function deleteKnowledgeBaseCategorySectionInCategory({ id, categoryId }: { id: string; categoryId: string }) {
return await db.knowledgeBaseCategorySection.deleteMany({
where: { id, categoryId },
});
}
@@ -0,0 +1,154 @@
import { KnowledgeBase } from "@toolctrl/db/prisma-browser";
import { clearCacheKey } from "~/utils/cache.server";
import { db, dbRead } from "~/utils/db.server";
export type KnowledgeBaseWithDetails = KnowledgeBase & {
_count: {
categories: number;
articles: number;
views: number;
};
};
const include = {
_count: {
select: { categories: true, articles: true, views: true },
},
};
function knowledgeBaseOwnerKey(tenantId?: string | null) {
return tenantId ?? "global";
}
export async function getAllKnowledgeBases({ enabled, tenantId }: { enabled?: boolean; tenantId?: string | null } = {}): Promise<KnowledgeBaseWithDetails[]> {
// Listing for KB management UI and public KB discovery — read-only.
return await dbRead.knowledgeBase.findMany({
where: { enabled, ownerKey: knowledgeBaseOwnerKey(tenantId) },
include,
orderBy: {
createdAt: "asc",
},
});
}
export async function getAllKnowledgeBaseNames({ tenantId }: { tenantId?: string | null } = {}): Promise<{ id: string; title: string }[]> {
// Simple enumeration — read-only.
return await dbRead.knowledgeBase.findMany({
where: { ownerKey: knowledgeBaseOwnerKey(tenantId) },
select: {
id: true,
title: true,
},
orderBy: {
createdAt: "asc",
},
});
}
export async function getKnowledgeBaseById(id: string): Promise<KnowledgeBaseWithDetails | null> {
// Read-only lookup.
return await dbRead.knowledgeBase.findUnique({
where: { id },
include,
});
}
export async function getKnowledgeBaseBySlug(slug: string, { tenantId }: { tenantId?: string | null } = {}): Promise<KnowledgeBaseWithDetails | null> {
// Public route lookup, read-only.
return await dbRead.knowledgeBase.findUnique({
where: { ownerKey_slug: { ownerKey: knowledgeBaseOwnerKey(tenantId), slug } },
include,
});
}
export async function createKnowledgeBase(data: {
basePath: string;
slug: string;
tenantId?: string | null;
title: string;
description: string;
defaultLanguage: string;
layout: string;
color: number;
enabled: boolean;
languages: string;
links: string;
logo: string;
seoImage: string;
}) {
return await db.knowledgeBase.create({
data: {
basePath: data.basePath,
slug: data.slug,
tenantId: data.tenantId ?? null,
ownerKey: knowledgeBaseOwnerKey(data.tenantId),
title: data.title,
description: data.description,
defaultLanguage: data.defaultLanguage,
layout: data.layout,
color: data.color,
enabled: data.enabled,
languages: data.languages,
links: data.links,
logo: data.logo,
seoImage: data.seoImage,
},
});
}
export async function updateKnowledgeBase(
id: string,
data: {
basePath?: string;
slug?: string;
title?: string;
description?: string;
defaultLanguage?: string;
layout?: string;
color?: number;
enabled?: boolean;
languages?: string;
links?: string;
logo?: string;
seoImage?: string;
}
) {
return await db.knowledgeBase
.update({
where: { id },
data: {
basePath: data.basePath,
slug: data.slug,
title: data.title,
description: data.description,
defaultLanguage: data.defaultLanguage,
layout: data.layout,
color: data.color,
enabled: data.enabled,
languages: data.languages,
links: data.links,
logo: data.logo,
seoImage: data.seoImage,
},
})
.then((item) => {
clearCacheKey(`kb:${item.slug}`);
return item;
});
}
export async function deleteKnowledgeBase(id: string) {
return await db.knowledgeBase
.delete({
where: { id },
})
.then((item) => {
clearCacheKey(`kb:${item.slug}`);
return item;
});
}
export async function countKnowledgeBases() {
// Dashboard count — read-only.
return await dbRead.knowledgeBase.count();
}
@@ -0,0 +1,30 @@
import type { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
export type KbArticleDto = {
id: string;
createdAt: Date;
updatedAt: Date | null;
slug: string;
title: string;
description: string;
order: number;
contentDraft: string;
contentPublished: string;
contentPublishedAsText: string;
contentType: "markdown" | "wysiwyg";
language: string;
featuredOrder: number | null;
createdByUser: {
firstName: string;
lastName: string;
avatar: string | null;
} | null;
seoImage: string;
publishedAt: Date | null;
href: string;
sectionId: string | null;
relatedArticles: { order: number; title: string; slug: string; href: string }[];
metatags: MetaTagsDto;
// tags: KnowledgeBaseTagDto[];
// reactions: KnowledgeBaseArticleReactionDto[];
};
@@ -0,0 +1,27 @@
import type { KbCategorySectionDto } from "./KbCategorySectionDto";
import type { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
export type KbCategoryDto = {
id: string;
slug: string;
order: number;
title: string;
description: string;
icon: string;
language: string;
seoImage: string;
href: string;
sections: KbCategorySectionDto[];
articles: {
id: string;
order: number;
title: string;
description: string;
slug: string;
href: string;
sectionId: string | null;
publishedAt: Date | null;
categoryId: string;
}[];
metatags: MetaTagsDto;
};
@@ -0,0 +1,6 @@
export type KbCategorySectionDto = {
id: string;
order: number;
title: string;
description: string;
};
@@ -0,0 +1,5 @@
export type KbNavLinkDto = {
name: string;
href: string;
order: number;
};
@@ -0,0 +1,6 @@
import { type KbArticleDto } from "./KbArticleDto";
export type KbSearchResultDto = {
query: string;
articles: KbArticleDto[];
};
@@ -0,0 +1,27 @@
import type { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
import type { Colors } from "~/application/enums/shared/Colors";
import { KbNavLinkDto } from "./KbNavLinkDto";
export type KnowledgeBaseDto = {
id: string;
createdAt: Date;
updatedAt: Date | null;
basePath: string;
slug: string;
title: string;
description: string;
defaultLanguage: string;
layout: "list" | "articles" | "grid" | "docs";
color: Colors;
enabled: boolean;
metatags: MetaTagsDto;
languages: string[];
links: KbNavLinkDto[];
logo: string;
seoImage: string;
count: {
categories: number;
articles: number;
views: number;
};
};
@@ -0,0 +1,50 @@
export type KnowledgeBasesTemplateDto = {
knowledgeBases: {
basePath?: string;
slug: string;
title: string;
description: string;
defaultLanguage: string;
layout: string;
color: number;
enabled: boolean;
languages: string[];
links: { name: string; href: string; order: number }[];
logo: string;
seoImage: string;
}[];
categories: {
knowledgeBaseSlug: string;
slug: string;
order: number;
title: string;
description: string;
icon: string;
language: string;
seoImage: string;
sections: {
order: number;
title: string;
description: string;
}[];
}[];
articles: {
knowledgeBaseSlug: string;
categorySlug: string | null;
categorySectionOrder: number | null;
slug: string;
title: string;
description: string;
order: number;
contentDraft: string;
contentPublished: string;
contentPublishedAsText: string;
contentType: string;
language: string;
featuredOrder: number | null;
seoImage: string;
publishedAt: string | null;
createdByUserEmail: string | null;
relatedArticles: { slug: string }[];
}[];
};
@@ -0,0 +1,20 @@
import { KnowledgeBaseCategory } from "@toolctrl/db/prisma-browser";
export type KnowledgeBaseCategoryWithDetails = KnowledgeBaseCategory & {
articles: {
id: string;
order: number;
title: string;
description: string;
slug: string;
language: string;
sectionId: string | null;
publishedAt: Date | null;
}[];
sections: {
id: string;
order: number;
title: string;
description: string;
}[];
};
@@ -0,0 +1,97 @@
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from "react-router";
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
import { getUserInfo } from "~/utils/session.server";
import { createKnowledgeBaseArticleView, getArticleStateByUserAnalyticsId, voteArticle } from "../../db/kbAnalytics.db.server";
import { KbArticleDto } from "../../dtos/KbArticleDto";
import { KbCategoryDto } from "../../dtos/KbCategoryDto";
import { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
import KnowledgeBaseService from "../../service/KnowledgeBaseService.server";
import KnowledgeBaseUtils from "../../utils/KnowledgeBaseUtils";
import { KbSearchResultDto } from "../../dtos/KbSearchResultDto";
import { getUser } from "~/utils/db/users.db.server";
import { getAnalyticsInfo } from "~/utils/analyticsCookie.server";
import { getTranslations } from "~/locale/i18next.server";
export namespace KbRoutesArticleApi {
export type LoaderData = {
metatags?: MetaTagsDto;
kb: KnowledgeBaseDto;
search: KbSearchResultDto | undefined;
item: {
article: KbArticleDto;
category: KbCategoryDto;
} | null;
userState: {
hasThumbsUp: boolean;
hasThumbsDown: boolean;
};
isAdmin: boolean;
categories: KbCategoryDto[];
};
export const loader = async ({ request, params }: LoaderFunctionArgs, { kbSlug }: { kbSlug?: string } = {}) => {
const analyticsInfo = await getAnalyticsInfo(request);
const userInfo = await getUserInfo(request);
const kb = await KnowledgeBaseService.get({ slug: kbSlug ?? params.slug!, enabled: true, request });
const item = await KnowledgeBaseService.getArticle({
kb,
slug: params.article ?? "",
params,
request,
});
if (!item) {
throw redirect(KnowledgeBaseUtils.getKbUrl({ kb, params }));
}
if (item?.article) {
createKnowledgeBaseArticleView({ userAnalyticsId: analyticsInfo.userAnalyticsId, articleId: item.article.id });
}
let userState = await getArticleStateByUserAnalyticsId({
userAnalyticsId: analyticsInfo.userAnalyticsId,
articleId: item?.article.id ?? "",
});
const searchParams = new URL(request.url).searchParams;
const query = searchParams.get("q")?.toString();
const currentUser = await getUser(userInfo.userId);
KnowledgeBaseUtils.fixDarkMode({ article: item.article });
const data: LoaderData = {
metatags: item?.article.metatags,
kb,
search: await KnowledgeBaseService.search({ query, kb, params, request }),
item,
userState,
isAdmin: !!currentUser?.admin,
categories: await KnowledgeBaseService.getCategories({
kb,
params,
request,
}),
};
return data;
};
export const action = async ({ request, params }: ActionFunctionArgs, { kbSlug }: { kbSlug?: string } = {}) => {
const { t } = await getTranslations(request);
const { userAnalyticsId } = await getAnalyticsInfo(request);
const form = await request.formData();
const action = form.get("action") as string;
const kb = await KnowledgeBaseService.get({ slug: kbSlug ?? params.slug!, enabled: true, request });
const item = await KnowledgeBaseService.getArticle({
kb,
slug: params.article ?? "",
params,
request,
});
if (!item) {
return Response.json({ error: t("knowledgeBase.public.errors.articleNotFound") }, { status: 404 });
}
if (action === "thumbsUp") {
await voteArticle({ userAnalyticsId, articleId: item.article.id, type: "up" });
return Response.json({ success: true });
} else if (action === "thumbsDown") {
await voteArticle({ userAnalyticsId, articleId: item.article.id, type: "down" });
return Response.json({ success: true });
}
return Response.json({ error: t("knowledgeBase.public.errors.invalidAction") }, { status: 400 });
};
}
@@ -0,0 +1,48 @@
import { LoaderFunctionArgs, redirect } from "react-router";
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
import { KbCategoryDto } from "../../dtos/KbCategoryDto";
import { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
import KnowledgeBaseService from "../../service/KnowledgeBaseService.server";
import KnowledgeBaseUtils from "../../utils/KnowledgeBaseUtils";
import { KbSearchResultDto } from "../../dtos/KbSearchResultDto";
export namespace KbRoutesCategoryApi {
export type LoaderData = {
metatags?: MetaTagsDto;
kb: KnowledgeBaseDto;
search: KbSearchResultDto | undefined;
item: KbCategoryDto | null;
language: string;
allCategories: KbCategoryDto[];
};
export const loader = async ({ request, params }: LoaderFunctionArgs, { kbSlug }: { kbSlug?: string } = {}) => {
const kb = await KnowledgeBaseService.get({ slug: kbSlug ?? params.slug!, enabled: true, request });
const language = params.lang ?? kb.defaultLanguage;
const item = await KnowledgeBaseService.getCategory({
kb,
category: params.category ?? "",
language,
params,
request,
});
if (!item) {
throw redirect(KnowledgeBaseUtils.getKbUrl({ kb, params }));
}
const searchParams = new URL(request.url).searchParams;
const query = searchParams.get("q")?.toString();
const data: LoaderData = {
metatags: item?.metatags,
kb,
search: await KnowledgeBaseService.search({ query, kb, params, request }),
item,
allCategories: await KnowledgeBaseService.getCategories({
kb,
params,
request,
}),
language,
};
return data;
};
}
@@ -0,0 +1,55 @@
import { LoaderFunctionArgs } from "react-router";
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
import { createKnowledgeBaseView } from "../../db/kbAnalytics.db.server";
import { KbArticleDto } from "../../dtos/KbArticleDto";
import { KbCategoryDto } from "../../dtos/KbCategoryDto";
import { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
import KnowledgeBaseService from "../../service/KnowledgeBaseService.server";
import { KbSearchResultDto } from "../../dtos/KbSearchResultDto";
import KnowledgeBaseUtils from "../../utils/KnowledgeBaseUtils";
import { getAnalyticsInfo } from "~/utils/analyticsCookie.server";
import { getUserInfo } from "~/utils/session.server";
import { getUser } from "~/utils/db/users.db.server";
export namespace KbRoutesIndexApi {
export type LoaderData = {
metatags: MetaTagsDto;
kb: KnowledgeBaseDto;
search: KbSearchResultDto | undefined;
categories: KbCategoryDto[];
featured: KbArticleDto[];
isAdmin: boolean;
};
export const loader = async ({ request, params }: LoaderFunctionArgs, { kbSlug }: { kbSlug?: string } = {}) => {
if (params.lang && !KnowledgeBaseUtils.supportedLanguages.find((f) => f.value === params.lang)) {
throw Response.json({}, { status: 404 });
}
const kb = await KnowledgeBaseService.get({ slug: kbSlug ?? params.slug!, enabled: true, request });
const { userAnalyticsId } = await getAnalyticsInfo(request);
const userInfo = await getUserInfo(request);
await createKnowledgeBaseView({ userAnalyticsId, knowledgeBaseId: kb.id });
const searchParams = new URL(request.url).searchParams;
const query = searchParams.get("q")?.toString();
const currentUser = await getUser(userInfo.userId);
const data: LoaderData = {
metatags: kb.metatags,
kb,
search: await KnowledgeBaseService.search({ query, kb, params, request }),
categories: await KnowledgeBaseService.getCategories({
kb,
params,
request,
}),
featured: await KnowledgeBaseService.getFeaturedArticles({
kb,
params,
request,
}),
isAdmin: !!currentUser?.admin,
// searchResult,
};
return data;
};
}
@@ -0,0 +1,57 @@
import { useLoaderData, useSubmit } from "react-router";
import { useTranslation } from "react-i18next";
import KbHeader from "../../components/KbHeader";
import KbArticle from "../../components/articles/KbArticle";
import { KbRoutesArticleApi } from "../api/KbRoutes.Article.Api";
import KbDocsLayout from "../../components/layouts/KbDocsLayout";
export default function KbRoutesArticleView() {
const data = useLoaderData<KbRoutesArticleApi.LoaderData>();
const submit = useSubmit();
const { t } = useTranslation();
if (data.kb.layout === "docs") {
return (
<KbDocsLayout
kb={data.kb}
categories={data.categories}
article={!data.item ? null : { ...data.item, userState: data.userState }}
/>
);
}
function onAction(name: string) {
const form = new FormData();
form.set("action", name);
submit(form, {
method: "post",
});
}
return (
<div className="bg-background text-foreground min-h-screen">
<KbHeader kb={data.kb} withTitleAndDescription={false} withSearch={false} />
<div className="mx-auto max-w-5xl px-8 py-8">
<div className="space-y-3">
{!data.item ? (
<div>{t("knowledgeBase.public.errors.articleNotFound")}</div>
) : (
<KbArticle
kb={data.kb}
category={data.item.category}
item={data.item.article}
userState={{
hasThumbsUp: data.userState.hasThumbsUp,
hasThumbsDown: data.userState.hasThumbsDown,
}}
actions={{
onThumbsUp: () => onAction("thumbsUp"),
onThumbsDown: () => onAction("thumbsDown"),
}}
/>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,18 @@
import { useLoaderData } from "react-router";
import { useTranslation } from "react-i18next";
import KbHeader from "../../components/KbHeader";
import KbCategory from "../../components/categories/KbCategory";
import { KbRoutesCategoryApi } from "../api/KbRoutes.Category.Api";
export default function KbRoutesCategoryView() {
const data = useLoaderData<KbRoutesCategoryApi.LoaderData>();
const { t } = useTranslation();
return (
<div className="bg-background text-foreground min-h-screen">
<KbHeader kb={data.kb} withTitleAndDescription={false} />
<div className="mx-auto min-h-screen max-w-5xl px-8 py-8">
<div className="space-y-5">{!data.item ? <div>{t("knowledgeBase.public.errors.categoryNotFound")}</div> : <KbCategory kb={data.kb} item={data.item} allCategories={data.allCategories} />}</div>
</div>
</div>
);
}
@@ -0,0 +1,26 @@
import { KbRoutesIndexApi } from "../api/KbRoutes.Index.Api";
import KbHeader from "../../components/KbHeader";
import KbFeaturedArticles from "../../components/KbFeaturedArticles";
import KbCategories from "../../components/categories/KbCategories";
import KbDocsLayout from "../../components/layouts/KbDocsLayout";
import { useLoaderData } from "react-router";
export default function KbRoutesIndex() {
const data = useLoaderData<KbRoutesIndexApi.LoaderData>();
if (data.kb.layout === "docs") {
return <KbDocsLayout kb={data.kb} categories={data.categories} featured={data.featured} />;
}
return (
<div className="bg-background text-foreground min-h-screen">
<KbHeader kb={data.kb} withTitleAndDescription={true} />
<div className="mx-auto max-w-5xl px-2 py-6 sm:px-8">
<div className="space-y-8">
{data.featured.length > 0 && <KbFeaturedArticles kb={data.kb} items={data.featured} />}
<KbCategories kb={data.kb} items={data.categories} />
</div>
</div>
</div>
);
}
@@ -0,0 +1,148 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const getAllKnowledgeBaseArticlesMock = vi.fn();
const createKnowledgeBaseArticleMock = vi.fn();
const getAllKnowledgeBaseCategoriesMock = vi.fn();
const getFixedTMock = vi.fn();
const knowledgeBaseArticleCountMock = vi.fn();
const knowledgeBaseCategoryCountMock = vi.fn();
const knowledgeBaseDeleteMock = vi.fn();
const cachifiedMock = vi.fn();
vi.mock("../db/kbArticles.db.server", () => ({
createKnowledgeBaseArticle: (...args: unknown[]) => createKnowledgeBaseArticleMock(...args),
getAllKnowledgeBaseArticles: (...args: unknown[]) => getAllKnowledgeBaseArticlesMock(...args),
getFeaturedKnowledgeBaseArticles: vi.fn(),
getKbArticleById: vi.fn(),
getKbArticleByIdInKnowledgeBase: vi.fn(),
getKbArticleBySlug: vi.fn(),
}));
vi.mock("../db/kbCategories.db.server", () => ({
createKnowledgeBaseCategory: vi.fn(),
getAllKnowledgeBaseCategories: (...args: unknown[]) => getAllKnowledgeBaseCategoriesMock(...args),
getKbCategoryById: vi.fn(),
getKbCategoryBySlug: vi.fn(),
}));
vi.mock("../db/knowledgeBase.db.server", () => ({
getAllKnowledgeBases: vi.fn(),
getKnowledgeBaseById: vi.fn(),
getKnowledgeBaseBySlug: vi.fn(),
}));
vi.mock("../services/kbArticleSearch.server", () => ({
searchKnowledgeBaseArticles: vi.fn(),
}));
vi.mock("~/locale/i18next.server", () => ({
remixI18Next: {
getFixedT: (...args: unknown[]) => getFixedTMock(...args),
},
}));
vi.mock("~/utils/cache.server", () => ({
cachified: (...args: unknown[]) => cachifiedMock(...args),
}));
vi.mock("~/utils/db.server", () => ({
db: {
knowledgeBaseArticle: { count: (...args: unknown[]) => knowledgeBaseArticleCountMock(...args) },
knowledgeBaseCategory: { count: (...args: unknown[]) => knowledgeBaseCategoryCountMock(...args) },
knowledgeBase: { delete: (...args: unknown[]) => knowledgeBaseDeleteMock(...args) },
},
}));
describe("KnowledgeBaseServiceServer", () => {
beforeEach(() => {
vi.clearAllMocks();
getAllKnowledgeBaseArticlesMock.mockResolvedValue([
{
id: "article-1",
slug: "untitled",
order: 1,
},
]);
getAllKnowledgeBaseCategoriesMock.mockResolvedValue([]);
createKnowledgeBaseArticleMock.mockImplementation(async (input) => ({ id: "created", ...input }));
getFixedTMock.mockImplementation(async (locale: string) => (key: string, vars?: Record<string, unknown>) => `${locale}:${key}:${vars?.number ?? ""}`);
knowledgeBaseArticleCountMock.mockResolvedValue(0);
knowledgeBaseCategoryCountMock.mockResolvedValue(0);
knowledgeBaseDeleteMock.mockResolvedValue({ id: "kb-1" });
cachifiedMock.mockImplementation(async ({ getFreshValue }: { getFreshValue: () => unknown }) => getFreshValue());
});
test("newArticle localizes generated untitled titles and persists the resolved language", async () => {
const KnowledgeBaseServiceServer = (await import("./KnowledgeBaseService.server")).default;
const created = await KnowledgeBaseServiceServer.newArticle({
kb: {
id: "kb-1",
slug: "docs",
defaultLanguage: "de",
} as never,
params: {},
userId: "user-1",
position: "last",
});
expect(getFixedTMock).toHaveBeenCalledWith("de", "translations");
expect(createKnowledgeBaseArticleMock).toHaveBeenCalledWith(expect.objectContaining({ title: "de:knowledgeBase.adminCategories.untitledNumbered:1", language: "de" }));
expect(created).toMatchObject({ title: "de:knowledgeBase.adminCategories.untitledNumbered:1", language: "de" });
});
test("del exposes coded delete guard errors for localized route handling", async () => {
const { default: KnowledgeBaseServiceServer, getKnowledgeBaseActionErrorKey } = await import("./KnowledgeBaseService.server");
knowledgeBaseArticleCountMock.mockResolvedValueOnce(1);
try {
await KnowledgeBaseServiceServer.del({ id: "kb-1" } as never);
throw new Error("Expected delete to reject");
} catch (error) {
expect(error).toMatchObject({ code: "cannot-delete-with-articles" });
expect(getKnowledgeBaseActionErrorKey(error)).toBe("knowledgeBase.admin.errors.cannotDeleteWithArticles");
}
});
test("duplicate and new article validation errors map to semantic i18n keys", async () => {
const { default: KnowledgeBaseServiceServer, getKnowledgeBaseActionErrorKey } = await import("./KnowledgeBaseService.server");
await expect(
KnowledgeBaseServiceServer.duplicateArticle({ kb: { id: "kb-1", slug: "docs", defaultLanguage: "en" } as never, language: "en", articleId: "missing" })
).rejects.toMatchObject({ code: "invalid-article" });
try {
await KnowledgeBaseServiceServer.newArticle({
kb: { id: "kb-1", slug: "docs", defaultLanguage: "en" } as never,
params: {},
userId: "user-1",
position: "last",
categoryId: "missing",
});
} catch (error) {
expect(getKnowledgeBaseActionErrorKey(error)).toBe("knowledgeBase.adminCategories.errors.categoryNotFound");
}
});
test("public lookup errors map to public i18n keys", async () => {
const { default: KnowledgeBaseServiceServer, getKnowledgeBasePublicErrorKey } = await import("./KnowledgeBaseService.server");
cachifiedMock.mockResolvedValueOnce(null);
try {
await KnowledgeBaseServiceServer.get({ slug: "missing", enabled: true, request: new Request("https://example.com/help") });
throw new Error("Expected lookup to reject");
} catch (error) {
expect(error).toMatchObject({ code: "not-found" });
expect(getKnowledgeBasePublicErrorKey(error)).toBe("knowledgeBase.public.errors.notFound");
}
cachifiedMock.mockResolvedValueOnce({ title: "Draft docs", enabled: false });
try {
await KnowledgeBaseServiceServer.get({ slug: "draft", enabled: true, request: new Request("https://example.com/help") });
throw new Error("Expected disabled knowledge base to reject");
} catch (error) {
expect(error).toMatchObject({ code: "disabled" });
expect(getKnowledgeBasePublicErrorKey(error)).toBe("knowledgeBase.public.errors.disabled");
}
});
});
@@ -0,0 +1,648 @@
import type { KbArticleDto } from "../dtos/KbArticleDto";
import type { KbCategoryDto } from "../dtos/KbCategoryDto";
import type { KbSearchResultDto } from "../dtos/KbSearchResultDto";
import type { KnowledgeBaseDto } from "../dtos/KnowledgeBaseDto";
import { KnowledgeBaseWithDetails, getAllKnowledgeBases, getKnowledgeBaseById, getKnowledgeBaseBySlug } from "../db/knowledgeBase.db.server";
import { db } from "~/utils/db.server";
import { KbNavLinkDto } from "../dtos/KbNavLinkDto";
import {
KnowledgeBaseArticleWithDetails,
createKnowledgeBaseArticle,
getAllKnowledgeBaseArticles,
getFeaturedKnowledgeBaseArticles,
getKbArticleByIdInKnowledgeBase,
getKbArticleBySlug,
} from "../db/kbArticles.db.server";
import { searchKnowledgeBaseArticles } from "../services/kbArticleSearch.server";
import { createKnowledgeBaseCategory, getAllKnowledgeBaseCategories, getKbCategoryById, getKbCategoryBySlug } from "../db/kbCategories.db.server";
import KnowledgeBaseUtils from "../utils/KnowledgeBaseUtils";
import { KnowledgeBaseCategoryWithDetails } from "../helpers/KbCategoryModelHelper";
import { KnowledgeBaseRelatedArticle } from "@toolctrl/db/prisma-browser";
import { defaultLocale, isSupportedLocale } from "@toolctrl/i18n";
import { marked } from "marked";
import { htmlToText } from "html-to-text";
import { remixI18Next } from "~/locale/i18next.server";
import { cachified } from "~/utils/cache.server";
import { getBaseURL } from "~/utils/url.server";
export type KnowledgeBaseServiceErrorCode =
| "cannot-delete-with-articles"
| "cannot-delete-with-categories"
| "disabled"
| "invalid-category"
| "invalid-article"
| "invalid-section"
| "not-found";
const knowledgeBaseServiceErrorKeys: Record<KnowledgeBaseServiceErrorCode, string> = {
"cannot-delete-with-articles": "knowledgeBase.admin.errors.cannotDeleteWithArticles",
"cannot-delete-with-categories": "knowledgeBase.admin.errors.cannotDeleteWithCategories",
disabled: "knowledgeBase.public.errors.disabled",
"invalid-category": "knowledgeBase.adminCategories.errors.categoryNotFound",
"invalid-article": "knowledgeBase.adminCategories.errors.articleNotFound",
"invalid-section": "knowledgeBase.adminCategories.errors.sectionNotFound",
"not-found": "knowledgeBase.public.errors.notFound",
};
export class KnowledgeBaseServiceError extends Error {
constructor(readonly code: KnowledgeBaseServiceErrorCode) {
super(code);
this.name = "KnowledgeBaseServiceError";
}
}
export function getKnowledgeBaseActionErrorKey(error: unknown): string {
if (error instanceof KnowledgeBaseServiceError) {
return knowledgeBaseServiceErrorKeys[error.code];
}
return "knowledgeBase.admin.errors.actionFailed";
}
export function getKnowledgeBasePublicErrorKey(error: unknown): string {
if (error instanceof KnowledgeBaseServiceError && (error.code === "not-found" || error.code === "disabled")) {
return knowledgeBaseServiceErrorKeys[error.code];
}
return "knowledgeBase.public.errors.unavailable";
}
async function getAll({ enabled, request }: { enabled?: boolean; request: Request }): Promise<KnowledgeBaseDto[]> {
const items = await getAllKnowledgeBases({ enabled });
return items.map((item) => kbToDto(item, request));
}
async function get({ slug, enabled, request }: { slug: string; enabled?: boolean; request: Request }): Promise<KnowledgeBaseDto> {
const item = await cachified({
key: `kb:${slug}`,
ttl: 60 * 60 * 24,
getFreshValue: () => getKnowledgeBaseBySlug(slug),
});
if (!item) {
throw new KnowledgeBaseServiceError("not-found");
}
if (enabled !== undefined && item.enabled !== enabled) {
throw new KnowledgeBaseServiceError("disabled");
}
return kbToDto(item, request);
}
async function getById({ id, request }: { id: string; request: Request }): Promise<KnowledgeBaseDto | null> {
const item = await getKnowledgeBaseById(id);
if (!item) {
return null;
}
return kbToDto(item, request);
}
function kbToDto(kb: KnowledgeBaseWithDetails, request: Request) {
const baseUrl = getBaseURL(request, { https: true });
const item: KnowledgeBaseDto = {
id: kb.id,
createdAt: kb.createdAt,
updatedAt: kb.updatedAt,
basePath: kb.basePath,
slug: kb.slug,
title: kb.title,
description: kb.description,
defaultLanguage: kb.defaultLanguage,
layout: kb.layout as "list" | "articles" | "grid",
languages: JSON.parse(kb.languages) as string[],
links: JSON.parse(kb.links) as KbNavLinkDto[],
color: kb.color,
enabled: kb.enabled,
count: {
articles: kb._count.articles,
categories: kb._count.categories,
views: kb._count.views,
},
logo: kb.logo,
seoImage: kb.seoImage,
metatags: [
{ title: `${kb.title}` },
{ name: "description", content: kb.description },
// { name: "keywords", content: kb.keywords },
{ property: "og:title", content: `${kb.title}` },
{ property: "og:description", content: kb.description },
{ property: "og:image", content: kb.seoImage },
{ property: "og:type", content: "website" },
{ property: "og:url", content: `${baseUrl}${KnowledgeBaseUtils.getKbUrl({ kb, params: {} })}` },
{ property: "twitter:title", content: `${kb.title}` },
{ property: "twitter:description", content: kb.description },
{ property: "twitter:image", content: kb.seoImage },
{ property: "twitter:card", content: "summary_large_image" },
{
tagName: "link",
rel: "canonical",
href: `${baseUrl}${KnowledgeBaseUtils.getKbUrl({ kb, params: {} })}`,
},
],
};
return item;
}
async function getCategories({
kb,
params,
request,
}: {
kb: KnowledgeBaseDto;
params: {
lang?: string;
};
request: Request;
}): Promise<KbCategoryDto[]> {
const items = await getAllKnowledgeBaseCategories({
knowledgeBaseSlug: kb.slug,
language: params.lang || kb.defaultLanguage,
});
items.forEach((category) => {
category.articles = category.articles.filter((f) => f.publishedAt);
});
return items.map((f) => categoryToDto({ kb, category: f, params, request }));
// let allItems = await generateFakeData(kb);
// allItems.categories = allItems.categories.filter((f) => f.language === language);
// const result = await fakeSearch({ original: allItems, query });
// allItems.categories.forEach((category) => {
// category.articles = allItems.articles;
// });
// return result.categories;
}
function categoryToDto({
kb,
category,
params,
request,
}: {
kb: KnowledgeBaseDto;
category: KnowledgeBaseCategoryWithDetails;
params: { lang?: string };
request: Request;
}) {
const baseUrl = getBaseURL(request, { https: true });
const item: KbCategoryDto = {
id: category.id,
slug: category.slug,
order: category.order,
title: category.title,
description: category.description,
icon: category.icon,
language: category.language,
seoImage: category.seoImage,
href: KnowledgeBaseUtils.getCategoryUrl({ kb, category, params }),
sections: category.sections.map((f) => ({
id: f.id,
order: f.order,
title: f.title,
description: f.description,
})),
articles: category.articles.map((f) => ({
id: f.id,
order: f.order,
title: f.title,
description: f.description,
language: f.language,
slug: f.slug,
href: KnowledgeBaseUtils.getArticleUrl({ kb, article: f, params }),
sectionId: f.sectionId,
publishedAt: f.publishedAt,
categoryId: f.id,
})),
metatags: [
{ title: `${category.title} | ${kb.title}` },
{ name: "description", content: category.description },
// { name: "keywords", content: category.keywords },
{ property: "og:title", content: `${category.title} | ${kb.title}` },
{ property: "og:description", content: category.description },
{ property: "og:image", content: category.seoImage },
{ property: "og:type", content: "website" },
{ property: "og:url", content: `${baseUrl}${KnowledgeBaseUtils.getCategoryUrl({ kb, category, params })}` },
{ property: "twitter:title", content: `${category.title} | ${kb.title}` },
{ property: "twitter:description", content: category.description },
{ property: "twitter:image", content: category.seoImage },
{ property: "twitter:card", content: "summary_large_image" },
{
tagName: "link",
rel: "canonical",
href: `${baseUrl}${KnowledgeBaseUtils.getCategoryUrl({ kb, category, params })}`,
},
],
};
return item;
}
async function getArticles({
kb,
categoryId,
language,
params,
request,
}: {
kb: KnowledgeBaseDto;
categoryId: string;
language: string;
query: string | undefined;
params: { lang?: string };
request: Request;
}): Promise<KbArticleDto[]> {
const items = await getAllKnowledgeBaseArticles({
knowledgeBaseSlug: kb.slug,
categoryId,
language,
});
return items.map((f) => articleToDto({ kb, article: f, relatedArticles: f.relatedArticles, params, request }));
// let allItems = await generateFakeData(kb);
// allItems.articles = allItems.articles.filter((f) => f.language === language);
// const result = await fakeSearch({ original: allItems, query });
// return result.articles;
}
async function getArticle({ kb, slug, params, request }: { kb: KnowledgeBaseDto; slug: string; params: { lang?: string }; request: Request }): Promise<{
article: KbArticleDto;
category: KbCategoryDto;
} | null> {
let language = params.lang || kb.defaultLanguage;
const item = await getKbArticleBySlug({
knowledgeBaseId: kb.id,
slug,
language,
});
if (!item || !item.publishedAt) {
return null;
}
if (!item.categoryId) {
return null;
}
const category = await getKbCategoryById(item.categoryId);
if (!category) {
return null;
}
return {
article: articleToDto({ kb, article: item, relatedArticles: item.relatedArticles, params, request }),
category: categoryToDto({ kb, category, params, request }),
};
// const getIdFromSlug = (slug: string) => {
// return slug.split("-").pop();
// };
// const id = getIdFromSlug(slug);
// const allItems = await generateFakeData(kb);
// const article = allItems.articles.find((f) => f.id === id && f.language === language);
// if (!article) {
// return null;
// }
// const category = allItems.categories[0];
// return { article, category };
}
async function getArticleById({
kb,
id,
params,
request,
}: {
kb: KnowledgeBaseDto;
id: string;
params?: { lang?: string };
request: Request;
}): Promise<KbArticleDto | null> {
const item = await getKbArticleByIdInKnowledgeBase({ id, knowledgeBaseId: kb.id, language: params?.lang || kb.defaultLanguage });
if (!item) {
return null;
}
return articleToDto({ kb, article: item, relatedArticles: item.relatedArticles, params: {}, request });
}
async function getFeaturedArticles({ kb, params, request }: { kb: KnowledgeBaseDto; params: { lang?: string }; request: Request }) {
const items = await getFeaturedKnowledgeBaseArticles({
knowledgeBaseId: kb.id,
language: params.lang || kb.defaultLanguage,
});
return items.map((f) => articleToDto({ kb, article: f, relatedArticles: f.relatedArticles, params, request }));
// let allItems = await generateFakeData(kb);
// allItems.articles = allItems.articles.filter((f) => f.language === language && f.featuredOrder);
// // return first 6
// if (allItems.articles.length <= 4) {
// return allItems.articles;
// }
// return allItems.articles.slice(0, 4);
}
function articleToDto({
kb,
article,
relatedArticles,
params,
request,
}: {
kb: KnowledgeBaseDto;
article: KnowledgeBaseArticleWithDetails;
relatedArticles: (KnowledgeBaseRelatedArticle & {
relatedArticle: { id: string; order: number; title: string; slug: string };
})[];
params: { lang?: string };
request: Request;
}) {
const baseUrl = getBaseURL(request, { https: true });
const item: KbArticleDto = {
id: article.id,
createdAt: article.createdAt,
updatedAt: article.updatedAt,
slug: article.slug,
title: article.title,
description: article.description,
order: article.order,
contentDraft: article.contentDraft,
contentPublished: article.contentPublished,
contentPublishedAsText: article.contentPublishedAsText,
contentType: article.contentType as "wysiwyg" | "markdown",
language: article.language,
featuredOrder: article.featuredOrder,
createdByUser: article.createdByUser,
publishedAt: article.publishedAt,
href: KnowledgeBaseUtils.getArticleUrl({ kb, article, params }),
sectionId: article.sectionId,
relatedArticles: relatedArticles.map((f) => ({
order: f.relatedArticle.order,
title: f.relatedArticle.title,
slug: f.relatedArticle.slug,
href: KnowledgeBaseUtils.getArticleUrl({ kb, article: f.relatedArticle, params }),
})),
seoImage: article.seoImage,
metatags: [
{ title: `${article.title} | ${kb.title}` },
{ name: "description", content: article.description },
// { name: "keywords", content: article.keywords },
{ property: "og:title", content: `${article.title}` },
{ property: "og:description", content: article.description },
{ property: "og:image", content: article.seoImage },
{ property: "og:type", content: "article" },
{ property: "og:url", content: `${baseUrl}${KnowledgeBaseUtils.getArticleUrl({ kb, article, params })}` },
{ property: "twitter:title", content: `${article.title}` },
{ property: "twitter:description", content: article.description },
{ property: "twitter:image", content: article.seoImage },
{ property: "twitter:card", content: "summary_large_image" },
{
tagName: "link",
rel: "canonical",
href: `${baseUrl}${KnowledgeBaseUtils.getArticleUrl({ kb, article, params })}`,
},
],
};
return item;
}
async function getCategory({
kb,
category,
language,
params,
request,
}: {
kb: KnowledgeBaseDto;
category: string;
language: string;
params: { lang?: string };
request: Request;
}): Promise<KbCategoryDto | null> {
const item = await getKbCategoryBySlug({
knowledgeBaseId: kb.id,
slug: category,
language,
});
if (!item) {
return null;
}
item.articles = item.articles.filter((f) => f.publishedAt);
return categoryToDto({ kb, category: item, params, request });
}
async function del(item: KnowledgeBaseDto) {
const articlesCount = await db.knowledgeBaseArticle.count({
where: { knowledgeBaseId: item.id },
});
const categoriesCount = await db.knowledgeBaseCategory.count({
where: { knowledgeBaseId: item.id },
});
if (articlesCount > 0) {
throw new KnowledgeBaseServiceError("cannot-delete-with-articles");
}
if (categoriesCount > 0) {
throw new KnowledgeBaseServiceError("cannot-delete-with-categories");
}
return await db.knowledgeBase.delete({
where: { id: item.id },
});
}
async function duplicateCategory({ kb, language, categoryId }: { kb: KnowledgeBaseDto; language: string; categoryId: string }) {
const allCategories = await getAllKnowledgeBaseCategories({
knowledgeBaseSlug: kb.slug,
language,
});
const existing = allCategories.find((p) => p.id === categoryId);
if (!existing) {
throw new KnowledgeBaseServiceError("invalid-category");
}
let number = 2;
let slug = "";
const findExistingSlug = (slug: string) => {
return allCategories.find((p) => p.slug === slug);
};
do {
slug = `${existing.slug}${number}`;
const existingWithSlug = findExistingSlug(slug);
if (!existingWithSlug) {
break;
}
// if (number > 10) {
// throw Error("Too many duplicates");
// }
number++;
} while (true);
let maxOrder = 0;
if (allCategories.length > 0) {
maxOrder = Math.max(...allCategories.map((p) => p.order));
}
const item = await createKnowledgeBaseCategory({
knowledgeBaseId: kb.id,
slug,
order: maxOrder + 1,
title: existing.title + " " + number,
description: existing.description,
icon: existing.icon,
language: existing.language,
seoImage: existing.seoImage,
});
return item;
}
async function duplicateArticle({ kb, language, articleId }: { kb: KnowledgeBaseDto; language: string; articleId: string }) {
const allArticles = await getAllKnowledgeBaseArticles({
knowledgeBaseSlug: kb.slug,
language,
});
const existing = allArticles.find((p) => p.id === articleId);
if (!existing) {
throw new KnowledgeBaseServiceError("invalid-article");
}
const { slug, maxOrder, number } = KnowledgeBaseUtils.getAvailableArticleSlug({
allArticles,
initialSlug: existing.slug,
});
const item = await createKnowledgeBaseArticle({
knowledgeBaseId: kb.id,
categoryId: existing.categoryId,
sectionId: existing.sectionId,
slug,
title: existing.title + " " + number,
description: existing.description,
order: maxOrder + 1,
contentDraft: existing.contentDraft,
contentPublished: existing.contentPublished,
contentPublishedAsText: existing.contentPublishedAsText,
contentType: existing.contentType,
language: existing.language,
featuredOrder: existing.featuredOrder,
createdByUserId: existing.createdByUserId,
seoImage: existing.seoImage,
publishedAt: null,
});
return item;
}
async function search({
query,
kb,
params,
request,
}: {
query: string | undefined;
kb: KnowledgeBaseDto;
params: { lang?: string };
request: Request;
}): Promise<KbSearchResultDto | undefined> {
if (!query) {
return undefined;
}
// Hot-path: routes the read through @toolctrl/search's tenant-scoped
// wrapper when MEILI_URL is set; transparently falls back to the SQL
// LIKE-based implementation on connectivity errors or when no
// Meilisearch backend is configured. See docs/features/search.md.
const kbWithTenant = (await getKnowledgeBaseById(kb.id)) as { tenantId?: string | null } | null;
const articles = await searchKnowledgeBaseArticles({
knowledgeBaseSlug: kb.slug,
language: params.lang || kb.defaultLanguage,
query,
tenantId: kbWithTenant?.tenantId ?? undefined,
});
const result: KbSearchResultDto = {
articles: articles.map((article) => articleToDto({ kb, article, relatedArticles: [], params, request })),
query,
};
return result;
}
async function newArticle({
kb,
title,
categoryId,
sectionId,
initialSlug = "untitled",
params,
userId,
position,
}: {
kb: KnowledgeBaseDto;
title?: string;
categoryId?: string | null;
sectionId?: string | null;
initialSlug?: string;
params: { lang?: string };
userId: string;
position: "first" | "last";
}) {
const language = params.lang || kb.defaultLanguage;
const allArticles = await getAllKnowledgeBaseArticles({
knowledgeBaseSlug: kb.slug,
language,
});
if (categoryId || sectionId) {
const allCategories = await getAllKnowledgeBaseCategories({
knowledgeBaseSlug: kb.slug,
language,
});
const category = categoryId ? allCategories.find((item) => item.id === categoryId) : null;
if (categoryId && !category) {
throw new KnowledgeBaseServiceError("invalid-category");
}
if (sectionId && !category?.sections.some((section) => section.id === sectionId)) {
throw new KnowledgeBaseServiceError("invalid-section");
}
}
const { slug, maxOrder, firstOrder, number } = KnowledgeBaseUtils.getAvailableArticleSlug({
allArticles,
initialSlug,
});
const created = await createKnowledgeBaseArticle({
knowledgeBaseId: kb.id,
categoryId: categoryId ?? null,
sectionId: sectionId ?? null,
slug,
title: title ?? (await getUntitledArticleTitle({ language, number })),
description: "",
order: position === "first" ? firstOrder - 1 : maxOrder + 1,
contentDraft: "",
contentPublished: "",
contentPublishedAsText: "",
contentType: "wysiwyg",
language,
featuredOrder: null,
createdByUserId: userId,
seoImage: "",
publishedAt: null,
});
return created;
}
async function getUntitledArticleTitle({ language, number }: { language: string | undefined; number: number }) {
const locale = language && isSupportedLocale(language) ? language : defaultLocale;
const t = await remixI18Next.getFixedT(locale, "translations");
return t("knowledgeBase.adminCategories.untitledNumbered", { number }) as string;
}
function contentAsText(text: string) {
return htmlToText(marked.parse(text, { async: false }) as string, {
wordwrap: 130,
});
}
export default {
getAll,
get,
getById,
getCategories,
getArticles,
getArticle,
getArticleById,
getFeaturedArticles,
getCategory,
del,
duplicateCategory,
duplicateArticle,
search,
newArticle,
contentAsText,
};
@@ -0,0 +1,176 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
createKnowledgeBaseArticle: vi.fn(),
getKbArticleBySlug: vi.fn(),
getKnowledgeBaseArticlesForTemplateExport: vi.fn(),
updateKnowledgeBaseArticle: vi.fn(),
createKnowledgeBaseCategory: vi.fn(),
getKbCategoryBySlug: vi.fn(),
getKnowledgeBaseCategoriesForTemplateExport: vi.fn(),
updateKnowledgeBaseCategory: vi.fn(),
createKnowledgeBase: vi.fn(),
getAllKnowledgeBases: vi.fn(),
getKnowledgeBaseBySlug: vi.fn(),
updateKnowledgeBase: vi.fn(),
createKnowledgeBaseCategorySection: vi.fn(),
updateKnowledgeBaseCategorySection: vi.fn(),
adminGetAllUsersNames: vi.fn(),
}));
vi.mock("../db/kbArticles.db.server", () => ({
createKnowledgeBaseArticle: mocks.createKnowledgeBaseArticle,
getKbArticleBySlug: mocks.getKbArticleBySlug,
getKnowledgeBaseArticlesForTemplateExport: mocks.getKnowledgeBaseArticlesForTemplateExport,
updateKnowledgeBaseArticle: mocks.updateKnowledgeBaseArticle,
}));
vi.mock("../db/kbCategories.db.server", () => ({
createKnowledgeBaseCategory: mocks.createKnowledgeBaseCategory,
getKbCategoryBySlug: mocks.getKbCategoryBySlug,
getKnowledgeBaseCategoriesForTemplateExport: mocks.getKnowledgeBaseCategoriesForTemplateExport,
updateKnowledgeBaseCategory: mocks.updateKnowledgeBaseCategory,
}));
vi.mock("../db/knowledgeBase.db.server", () => ({
createKnowledgeBase: mocks.createKnowledgeBase,
getAllKnowledgeBases: mocks.getAllKnowledgeBases,
getKnowledgeBaseBySlug: mocks.getKnowledgeBaseBySlug,
updateKnowledgeBase: mocks.updateKnowledgeBase,
}));
vi.mock("../db/kbCategorySections.db.server", () => ({
createKnowledgeBaseCategorySection: mocks.createKnowledgeBaseCategorySection,
updateKnowledgeBaseCategorySection: mocks.updateKnowledgeBaseCategorySection,
}));
vi.mock("~/utils/db/users.db.server", () => ({
adminGetAllUsersNames: mocks.adminGetAllUsersNames,
}));
import KnowledgeBaseTemplatesService from "./KnowledgeBaseTemplatesService.server";
function knowledgeBase(overrides: { id: string; slug: string; title: string }) {
return {
id: overrides.id,
basePath: "/help",
slug: overrides.slug,
title: overrides.title,
description: `${overrides.title} description`,
defaultLanguage: "en",
layout: "docs",
color: 1,
enabled: true,
languages: JSON.stringify(["en", "de"]),
links: JSON.stringify([{ href: "/help", title: "Help" }]),
logo: "",
seoImage: "",
};
}
describe("KnowledgeBaseTemplatesService", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("exports multiple knowledge bases with flat category and article fetches", async () => {
mocks.getAllKnowledgeBases.mockResolvedValue([
knowledgeBase({ id: "kb_1", slug: "docs", title: "Docs" }),
knowledgeBase({ id: "kb_2", slug: "support", title: "Support" }),
]);
mocks.getKnowledgeBaseCategoriesForTemplateExport.mockResolvedValue([
{
id: "cat_1",
knowledgeBaseId: "kb_1",
slug: "start",
order: 1,
title: "Start",
description: "Start here",
icon: "book",
language: "en",
seoImage: "",
sections: [{ id: "section_1", order: 1, title: "Basics", description: "The basics" }],
},
{
id: "cat_2",
knowledgeBaseId: "kb_2",
slug: "faq",
order: 1,
title: "FAQ",
description: "Answers",
icon: "help",
language: "en",
seoImage: "",
sections: [],
},
]);
mocks.getKnowledgeBaseArticlesForTemplateExport.mockResolvedValue([
{
id: "article_1",
knowledgeBaseId: "kb_1",
slug: "welcome",
title: "Welcome",
description: "Welcome article",
order: 1,
contentDraft: "draft",
contentPublished: "published",
contentPublishedAsText: "published",
contentType: "markdown",
language: "en",
featuredOrder: null,
seoImage: "",
publishedAt: new Date("2026-01-01T00:00:00.000Z"),
category: { slug: "start", title: "Start" },
section: { order: 1, title: "Basics" },
createdByUser: { email: "author@example.com", firstName: "Ada", lastName: "Lovelace", avatar: null },
relatedArticles: [{ relatedArticle: { id: "article_2", order: 2, title: "Next", slug: "next" } }],
_count: { views: 0, upvotes: 0, downvotes: 0 },
},
{
id: "article_2",
knowledgeBaseId: "kb_2",
slug: "faq",
title: "FAQ",
description: "FAQ article",
order: 1,
contentDraft: "draft",
contentPublished: "published",
contentPublishedAsText: "published",
contentType: "markdown",
language: "en",
featuredOrder: 1,
seoImage: "",
publishedAt: null,
category: null,
section: null,
createdByUser: null,
relatedArticles: [],
_count: { views: 0, upvotes: 0, downvotes: 0 },
},
]);
const template = await KnowledgeBaseTemplatesService.getTemplate();
expect(mocks.getAllKnowledgeBases).toHaveBeenCalledTimes(1);
expect(mocks.getKnowledgeBaseCategoriesForTemplateExport).toHaveBeenCalledTimes(1);
expect(mocks.getKnowledgeBaseCategoriesForTemplateExport).toHaveBeenCalledWith({ knowledgeBaseIds: ["kb_1", "kb_2"] });
expect(mocks.getKnowledgeBaseArticlesForTemplateExport).toHaveBeenCalledTimes(1);
expect(mocks.getKnowledgeBaseArticlesForTemplateExport).toHaveBeenCalledWith({ knowledgeBaseIds: ["kb_1", "kb_2"] });
expect(template.knowledgeBases.map((kb) => kb.slug)).toEqual(["docs", "support"]);
expect(template.categories.map((category) => [category.knowledgeBaseSlug, category.slug])).toEqual([
["docs", "start"],
["support", "faq"],
]);
expect(template.articles.map((article) => [article.knowledgeBaseSlug, article.slug])).toEqual([
["docs", "welcome"],
["support", "faq"],
]);
expect(template.articles[0]).toMatchObject({
categorySlug: "start",
categorySectionOrder: 1,
createdByUserEmail: "author@example.com",
publishedAt: "2026-01-01T00:00:00.000Z",
relatedArticles: [{ slug: "next" }],
});
});
});
@@ -0,0 +1,291 @@
import type { KnowledgeBase, KnowledgeBaseArticle } from "@toolctrl/db/prisma-browser";
import {
createKnowledgeBaseArticle,
getKbArticleBySlug,
getKnowledgeBaseArticlesForTemplateExport,
updateKnowledgeBaseArticle,
} from "../db/kbArticles.db.server";
import {
createKnowledgeBaseCategory,
getKbCategoryBySlug,
getKnowledgeBaseCategoriesForTemplateExport,
updateKnowledgeBaseCategory,
} from "../db/kbCategories.db.server";
import { createKnowledgeBase, getAllKnowledgeBases, getKnowledgeBaseBySlug, updateKnowledgeBase } from "../db/knowledgeBase.db.server";
import { KbNavLinkDto } from "../dtos/KbNavLinkDto";
import { KnowledgeBasesTemplateDto } from "../dtos/KnowledgeBasesTemplateDto";
import { KnowledgeBaseCategoryWithDetails } from "../helpers/KbCategoryModelHelper";
import { createKnowledgeBaseCategorySection, updateKnowledgeBaseCategorySection } from "../db/kbCategorySections.db.server";
import { adminGetAllUsersNames } from "~/utils/db/users.db.server";
async function getTemplate(): Promise<KnowledgeBasesTemplateDto> {
const template: KnowledgeBasesTemplateDto = {
knowledgeBases: [],
categories: [],
articles: [],
};
const allKbs = await getAllKnowledgeBases();
const categoriesByKnowledgeBaseId = groupByKnowledgeBaseId(
await getKnowledgeBaseCategoriesForTemplateExport({ knowledgeBaseIds: allKbs.map((kb) => kb.id) })
);
const articlesByKnowledgeBaseId = groupByKnowledgeBaseId(await getKnowledgeBaseArticlesForTemplateExport({ knowledgeBaseIds: allKbs.map((kb) => kb.id) }));
for (const kb of allKbs) {
template.knowledgeBases.push({
basePath: kb.basePath,
slug: kb.slug,
title: kb.title,
description: kb.description,
defaultLanguage: kb.defaultLanguage,
layout: kb.layout,
color: kb.color,
enabled: kb.enabled,
languages: JSON.parse(kb.languages) as string[],
links: JSON.parse(kb.links) as KbNavLinkDto[],
logo: kb.logo,
seoImage: kb.seoImage,
});
const allCategories = categoriesByKnowledgeBaseId.get(kb.id) ?? [];
for (const category of allCategories) {
template.categories.push({
knowledgeBaseSlug: kb.slug,
slug: category.slug,
order: category.order,
title: category.title,
description: category.description,
icon: category.icon,
language: category.language,
seoImage: category.seoImage,
sections: category.sections.map((section) => ({
order: section.order,
title: section.title,
description: section.description,
})),
});
}
const allArticles = articlesByKnowledgeBaseId.get(kb.id) ?? [];
for (const article of allArticles) {
template.articles.push({
knowledgeBaseSlug: kb.slug,
categorySlug: article.category?.slug ?? null,
categorySectionOrder: article.section?.order ?? null,
slug: article.slug,
title: article.title,
description: article.description,
order: article.order,
contentDraft: article.contentDraft,
contentPublished: article.contentPublished,
contentPublishedAsText: article.contentPublishedAsText,
contentType: article.contentType,
language: article.language,
featuredOrder: article.featuredOrder,
seoImage: article.seoImage,
publishedAt: article.publishedAt ? article.publishedAt.toISOString() : null,
createdByUserEmail: article.createdByUser?.email ?? null,
relatedArticles: article.relatedArticles.map(({ relatedArticle }) => ({
slug: relatedArticle.slug,
})),
});
}
}
return template;
}
function groupByKnowledgeBaseId<T extends { knowledgeBaseId: string }>(items: T[]) {
const grouped = new Map<string, T[]>();
for (const item of items) {
const current = grouped.get(item.knowledgeBaseId);
if (current) {
current.push(item);
} else {
grouped.set(item.knowledgeBaseId, [item]);
}
}
return grouped;
}
async function importKbs({ template, currentUserId }: { template: KnowledgeBasesTemplateDto; currentUserId: string }) {
let created = {
kbs: 0,
categories: 0,
sections: 0,
articles: 0,
};
let updated = {
kbs: 0,
categories: 0,
sections: 0,
articles: 0,
};
const allUsers = await adminGetAllUsersNames();
for (const kb of template.knowledgeBases) {
if (!kb.basePath) {
kb.basePath = "/";
}
let existing: KnowledgeBase | null = await getKnowledgeBaseBySlug(kb.slug);
if (existing) {
await updateKnowledgeBase(existing.id, {
basePath: kb.basePath,
slug: kb.slug,
title: kb.title,
description: kb.description,
defaultLanguage: kb.defaultLanguage,
layout: kb.layout,
color: kb.color,
enabled: kb.enabled,
languages: JSON.stringify(kb.languages),
links: JSON.stringify(kb.links),
logo: kb.logo,
seoImage: kb.seoImage,
});
updated.kbs++;
} else {
existing = await createKnowledgeBase({
basePath: kb.basePath,
slug: kb.slug,
title: kb.title,
description: kb.description,
defaultLanguage: kb.defaultLanguage,
layout: kb.layout,
color: kb.color,
enabled: kb.enabled,
languages: JSON.stringify(kb.languages),
links: JSON.stringify(kb.links),
logo: kb.logo,
seoImage: kb.seoImage,
});
created.kbs++;
}
for (const category of template.categories.filter((c) => c.knowledgeBaseSlug === kb.slug)) {
let existingCategory: KnowledgeBaseCategoryWithDetails | null = await getKbCategoryBySlug({
knowledgeBaseId: existing.id,
slug: category.slug,
language: category.language,
});
if (existingCategory) {
await updateKnowledgeBaseCategory(existingCategory.id, {
title: category.title,
order: category.order,
description: category.description,
icon: category.icon,
language: category.language,
seoImage: category.seoImage,
});
updated.categories++;
} else {
existingCategory = await createKnowledgeBaseCategory({
knowledgeBaseId: existing.id,
slug: category.slug,
title: category.title,
order: category.order,
description: category.description,
icon: category.icon,
language: category.language,
seoImage: category.seoImage,
});
created.categories++;
}
for (const section of category.sections) {
let existingSection: { id: string; order: number; title: string; description: string } | null = null;
if (existingCategory) {
existingSection = existingCategory.sections.find((s) => s.order === section.order) ?? null;
}
if (existingSection) {
await updateKnowledgeBaseCategorySection(existingSection.id, {
title: section.title,
description: section.description,
});
updated.sections++;
} else {
await createKnowledgeBaseCategorySection({
categoryId: existingCategory?.id ?? null,
order: section.order,
title: section.title,
description: section.description,
});
created.sections++;
}
}
}
for (const article of template.articles.filter((a) => a.knowledgeBaseSlug === kb.slug)) {
let existingArticle: KnowledgeBaseArticle | null = await getKbArticleBySlug({
knowledgeBaseId: existing.id,
slug: article.slug,
language: article.language,
});
let category: KnowledgeBaseCategoryWithDetails | null = null;
let sectionId: string | null = null;
if (article.categorySlug) {
category = await getKbCategoryBySlug({
knowledgeBaseId: existing.id,
slug: article.categorySlug,
language: article.language,
});
if (category && article.categorySectionOrder) {
const section = category.sections.find((s) => s.order === article.categorySectionOrder);
if (section) {
sectionId = section.id;
}
}
}
let createdByUserId = currentUserId;
if (article.createdByUserEmail) {
const user = allUsers.find((f) => f.email === article.createdByUserEmail);
if (user) {
createdByUserId = user.id;
}
}
if (existingArticle) {
await updateKnowledgeBaseArticle(existingArticle.id, {
categoryId: category?.id ?? null,
sectionId: sectionId,
slug: article.slug,
title: article.title,
description: article.description,
order: article.order,
contentDraft: article.contentDraft,
contentPublished: article.contentPublished,
contentPublishedAsText: article.contentPublishedAsText,
contentType: article.contentType,
language: article.language,
featuredOrder: article.featuredOrder,
createdByUserId,
seoImage: article.seoImage,
publishedAt: article.publishedAt ? new Date(article.publishedAt) : null,
});
updated.articles++;
} else {
existingArticle = await createKnowledgeBaseArticle({
knowledgeBaseId: existing.id,
categoryId: category?.id ?? null,
sectionId: sectionId,
slug: article.slug,
title: article.title,
description: article.description,
order: article.order,
contentDraft: article.contentDraft,
contentPublished: article.contentPublished,
contentPublishedAsText: article.contentPublishedAsText,
contentType: article.contentType,
language: article.language,
featuredOrder: article.featuredOrder,
createdByUserId,
seoImage: article.seoImage,
publishedAt: article.publishedAt ? new Date(article.publishedAt) : null,
});
created.articles++;
}
}
}
return {
created,
updated,
};
}
export default {
getTemplate,
importKbs,
};
@@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
createSearchClient: vi.fn(),
searchWithFallback: vi.fn(),
findKnowledgeBaseArticles: vi.fn(),
findKnowledgeBaseArticlesByIds: vi.fn(),
}));
vi.mock("@toolctrl/search", () => ({
createSearchClient: mocks.createSearchClient,
kbArticlesIndex: { name: "kb_articles", primaryKey: "id" },
searchWithFallback: mocks.searchWithFallback,
}));
vi.mock("../db/kbArticles.db.server", () => ({
findKnowledgeBaseArticles: mocks.findKnowledgeBaseArticles,
findKnowledgeBaseArticlesByIds: mocks.findKnowledgeBaseArticlesByIds,
}));
import { searchKnowledgeBaseArticles } from "./kbArticleSearch.server";
const originalEnv = { ...process.env };
describe("searchKnowledgeBaseArticles", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.MEILI_URL = "http://meili.test";
delete process.env.SEARCH_BACKEND;
mocks.createSearchClient.mockReturnValue({ index: vi.fn() });
});
afterEach(() => {
process.env = { ...originalEnv };
});
it("hydrates Meilisearch hits by ID instead of scanning the full SQL search set", async () => {
const articleA = { id: "article_a", title: "A" };
const articleB = { id: "article_b", title: "B" };
mocks.searchWithFallback.mockResolvedValue({
hits: [{ document: { id: "article_b" } }, { document: { id: "article_a" } }],
estimatedTotalHits: 2,
processingTimeMs: 3,
});
mocks.findKnowledgeBaseArticlesByIds.mockResolvedValue([articleA, articleB]);
await expect(
searchKnowledgeBaseArticles({
knowledgeBaseSlug: "docs",
language: "de",
query: "billing",
tenantId: "tenant_1",
})
).resolves.toEqual([articleB, articleA]);
expect(mocks.findKnowledgeBaseArticlesByIds).toHaveBeenCalledWith({
knowledgeBaseSlug: "docs",
language: "de",
ids: ["article_b", "article_a"],
});
expect(mocks.findKnowledgeBaseArticles).not.toHaveBeenCalled();
});
it("escapes the language value in the Meilisearch filter to prevent filter injection", async () => {
mocks.searchWithFallback.mockResolvedValue({ hits: [], estimatedTotalHits: 0, processingTimeMs: 1 });
await searchKnowledgeBaseArticles({
knowledgeBaseSlug: "docs",
// A crafted language attempting to break out of the quoted filter string.
language: 'de" OR isPublished = "true',
query: "billing",
tenantId: "tenant_1",
});
const passedOptions = mocks.searchWithFallback.mock.calls[0]?.[0]?.options;
expect(passedOptions.extraFilters).toBe('language = "de\\" OR isPublished = \\"true" AND isPublished = true');
});
});
@@ -0,0 +1,110 @@
/**
* KB-article public search — hot-path migration to Meilisearch.
*
* The function exposes the same return shape as the legacy
* `findKnowledgeBaseArticles` SQL implementation so the consuming
* KnowledgeBaseService.search code path can adopt it without further
* refactors. When MEILI_URL is unset (local dev that did not enable
* Meilisearch), or SEARCH_BACKEND=sql is forced, we transparently fall
* back to the legacy LIKE-based query. The same SQL path also engages
* automatically when Meilisearch raises a connectivity error — that is
* what `searchWithFallback` is for.
*/
import {
createSearchClient,
kbArticlesIndex,
searchWithFallback,
type SearchClient,
type SearchFallback,
} from "@toolctrl/search";
import {
findKnowledgeBaseArticlesByIds,
findKnowledgeBaseArticles,
type KnowledgeBaseArticleWithDetails,
} from "../db/kbArticles.db.server";
let cachedClient: SearchClient | undefined;
let cachedUrl: string | undefined;
// Escape values interpolated into a Meilisearch filter expression so a crafted
// `language` (sourced from the optional `($lang)` URL segment) cannot break out
// of the quoted string and inject filter operators. Mirrors rowSearch.server.ts.
function escapeFilterValue(value: string): string {
return value.replace(/\\/gu, "\\\\").replace(/"/gu, '\\"');
}
function getSearchClient(): SearchClient | undefined {
const url = process.env.MEILI_URL;
if (!url || process.env.SEARCH_BACKEND === "sql") return undefined;
if (cachedClient && cachedUrl === url) return cachedClient;
cachedClient = createSearchClient({ url, apiKey: process.env.MEILI_API_KEY ?? undefined });
cachedUrl = url;
return cachedClient;
}
export interface SearchKnowledgeBaseArticlesInput {
readonly knowledgeBaseSlug: string;
readonly language: string | undefined;
readonly query: string;
/**
* Tenant scope. Required when the Meilisearch path is engaged; the SQL
* fallback ignores it because the (knowledgeBaseSlug, language) filter
* already isolates results within a KB.
*/
readonly tenantId: string | undefined;
}
export async function searchKnowledgeBaseArticles(
input: SearchKnowledgeBaseArticlesInput
): Promise<KnowledgeBaseArticleWithDetails[]> {
const sqlFallback: SearchFallback = async () => {
const rows = await findKnowledgeBaseArticles({
knowledgeBaseSlug: input.knowledgeBaseSlug,
language: input.language,
query: input.query,
});
return {
hits: rows.map((row) => ({ document: { ...row, id: row.id, tenantId: input.tenantId ?? "" } as Record<string, unknown> & { id: string | number } })),
estimatedTotalHits: rows.length,
processingTimeMs: 0,
};
};
const client = getSearchClient();
if (!client || !input.tenantId) {
// No Meilisearch backend configured or no tenantId in scope —
// the SQL path is the authoritative source.
const rows = await findKnowledgeBaseArticles({
knowledgeBaseSlug: input.knowledgeBaseSlug,
language: input.language,
query: input.query,
});
return rows;
}
const hits = await searchWithFallback({
client,
schema: kbArticlesIndex,
tenantId: input.tenantId,
query: input.query,
fallback: sqlFallback,
options: input.language
? { extraFilters: `language = "${escapeFilterValue(input.language)}" AND isPublished = true` }
: { extraFilters: "isPublished = true" },
});
// The Meilisearch hit-stream doesn't carry the full ORM relation graph
// the route expects (category/section/related-articles); look the
// resolved IDs back up against the SQL store so the consumer keeps
// its existing typed return shape.
const ids = hits.hits.map((hit) => String(hit.document.id));
if (ids.length === 0) return [];
const rows = await findKnowledgeBaseArticlesByIds({
knowledgeBaseSlug: input.knowledgeBaseSlug,
language: input.language,
ids,
});
const byId = new Map(rows.map((row) => [row.id, row]));
return ids.map((id) => byId.get(id)).filter((row): row is KnowledgeBaseArticleWithDetails => row !== undefined);
}
@@ -0,0 +1,386 @@
import type { KnowledgeBasesTemplateDto } from "../dtos/KnowledgeBasesTemplateDto";
import deTranslations from "../../../../public/locales/de/translations.json";
import enTranslations from "../../../../public/locales/en/translations.json";
type SupportedTemplateLanguage = "en" | "de";
type TemplateCatalog = typeof enTranslations;
type TemplateKnowledgeBase = Omit<KnowledgeBasesTemplateDto["knowledgeBases"][number], "description" | "links" | "title"> & {
description: string;
links: (Omit<KnowledgeBasesTemplateDto["knowledgeBases"][number]["links"][number], "name"> & { name: string })[];
title: string;
};
type TemplateCategory = Omit<KnowledgeBasesTemplateDto["categories"][number], "description" | "sections" | "title"> & {
description: string;
sections: (Omit<KnowledgeBasesTemplateDto["categories"][number]["sections"][number], "description" | "title"> & {
description: string;
title: string;
})[];
title: string;
};
type TemplateArticle = Omit<
KnowledgeBasesTemplateDto["articles"][number],
"contentDraft" | "contentPublished" | "contentPublishedAsText" | "description" | "title"
> & {
contentDraft: string;
contentPublished: string;
contentPublishedAsText: string;
description: string;
title: string;
};
type TemplateSource = {
knowledgeBases: TemplateKnowledgeBase[];
categories: TemplateCategory[];
articles: TemplateArticle[];
};
const TEMPLATE_CATALOGS: Record<SupportedTemplateLanguage, TemplateCatalog> = {
de: deTranslations,
en: enTranslations,
};
function templateKey(path: string) {
return `knowledgeBase.defaultTemplates.sample.${path}`;
}
function getCatalogValue(catalog: TemplateCatalog, key: string) {
return key.split(".").reduce<unknown>((current, segment) => {
if (!current || typeof current !== "object" || !(segment in current)) {
return undefined;
}
return (current as Record<string, unknown>)[segment];
}, catalog);
}
function translateTemplateValue(language: SupportedTemplateLanguage, key: string) {
const value = getCatalogValue(TEMPLATE_CATALOGS[language], key);
if (typeof value === "string") {
return value;
}
const fallback = getCatalogValue(TEMPLATE_CATALOGS.en, key);
return typeof fallback === "string" ? fallback : key;
}
const SAMPLE_SOURCE: TemplateSource = {
knowledgeBases: [
{
basePath: "/",
slug: "docs",
title: templateKey("knowledgeBases.docs.title"),
description: templateKey("knowledgeBases.docs.description"),
defaultLanguage: "en",
layout: "list",
color: 15,
enabled: true,
languages: ["en"],
links: [],
logo: "dark",
seoImage: "",
},
{
basePath: "/docs",
slug: "center",
title: templateKey("knowledgeBases.center.title"),
description: templateKey("knowledgeBases.center.description"),
defaultLanguage: "en",
layout: "articles",
color: 16,
enabled: true,
languages: ["de", "en"],
links: [
{
name: templateKey("knowledgeBases.center.links.backToSite"),
href: "/",
order: 1,
},
],
logo: "dark",
seoImage: "",
},
],
categories: [
{
knowledgeBaseSlug: "docs",
slug: "introduction",
order: 1,
title: templateKey("categories.introduction.title"),
description: templateKey("categories.introduction.description"),
icon: "",
language: "en",
seoImage: "",
sections: [],
},
{
knowledgeBaseSlug: "center",
slug: "getting-started",
order: 1,
title: templateKey("categories.gettingStarted.title"),
description: templateKey("categories.gettingStarted.description"),
icon: '<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="50" height="50" viewBox="0 0 50 50" className={className} fill="currentColor"> <path d="M47.16,21.221l-5.91-0.966c-0.346-1.186-0.819-2.326-1.411-3.405l3.45-4.917c0.279-0.397,0.231-0.938-0.112-1.282 l-3.889-3.887c-0.347-0.346-0.893-0.391-1.291-0.104l-4.843,3.481c-1.089-0.602-2.239-1.08-3.432-1.427l-1.031-5.886 C28.607,2.35,28.192,2,27.706,2h-5.5c-0.49,0-0.908,0.355-0.987,0.839l-0.956,5.854c-1.2,0.345-2.352,0.818-3.437,1.412l-4.83-3.45 c-0.399-0.285-0.942-0.239-1.289,0.106L6.82,10.648c-0.343,0.343-0.391,0.883-0.112,1.28l3.399,4.863 c-0.605,1.095-1.087,2.254-1.438,3.46l-5.831,0.971c-0.482,0.08-0.836,0.498-0.836,0.986v5.5c0,0.485,0.348,0.9,0.825,0.985 l5.831,1.034c0.349,1.203,0.831,2.362,1.438,3.46l-3.441,4.813c-0.284,0.397-0.239,0.942,0.106,1.289l3.888,3.891 c0.343,0.343,0.884,0.391,1.281,0.112l4.87-3.411c1.093,0.601,2.248,1.078,3.445,1.424l0.976,5.861C21.3,47.647,21.717,48,22.206,48 h5.5c0.485,0,0.9-0.348,0.984-0.825l1.045-5.89c1.199-0.353,2.348-0.833,3.43-1.435l4.905,3.441 c0.398,0.281,0.938,0.232,1.282-0.111l3.888-3.891c0.346-0.347,0.391-0.894,0.104-1.292l-3.498-4.857 c0.593-1.08,1.064-2.222,1.407-3.408l5.918-1.039c0.479-0.084,0.827-0.5,0.827-0.985v-5.5C47.999,21.718,47.644,21.3,47.16,21.221z M25,32c-3.866,0-7-3.134-7-7c0-3.866,3.134-7,7-7s7,3.134,7,7C32,28.866,28.866,32,25,32z"></path> </svg>',
language: "en",
seoImage: "",
sections: [],
},
{
knowledgeBaseSlug: "center",
slug: "kategorie-1",
order: 1,
title: templateKey("categories.gettingStartedDe.title"),
description: templateKey("categories.gettingStartedDe.description"),
icon: '<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="50" height="50" viewBox="0 0 50 50" className={className} fill="currentColor"> <path d="M47.16,21.221l-5.91-0.966c-0.346-1.186-0.819-2.326-1.411-3.405l3.45-4.917c0.279-0.397,0.231-0.938-0.112-1.282 l-3.889-3.887c-0.347-0.346-0.893-0.391-1.291-0.104l-4.843,3.481c-1.089-0.602-2.239-1.08-3.432-1.427l-1.031-5.886 C28.607,2.35,28.192,2,27.706,2h-5.5c-0.49,0-0.908,0.355-0.987,0.839l-0.956,5.854c-1.2,0.345-2.352,0.818-3.437,1.412l-4.83-3.45 c-0.399-0.285-0.942-0.239-1.289,0.106L6.82,10.648c-0.343,0.343-0.391,0.883-0.112,1.28l3.399,4.863 c-0.605,1.095-1.087,2.254-1.438,3.46l-5.831,0.971c-0.482,0.08-0.836,0.498-0.836,0.986v5.5c0,0.485,0.348,0.9,0.825,0.985 l5.831,1.034c0.349,1.203,0.831,2.362,1.438,3.46l-3.441,4.813c-0.284,0.397-0.239,0.942,0.106,1.289l3.888,3.891 c0.343,0.343,0.884,0.391,1.281,0.112l4.87-3.411c1.093,0.601,2.248,1.078,3.445,1.424l0.976,5.861C21.3,47.647,21.717,48,22.206,48 h5.5c0.485,0,0.9-0.348,0.984-0.825l1.045-5.89c1.199-0.353,2.348-0.833,3.43-1.435l4.905,3.441 c0.398,0.281,0.938,0.232,1.282-0.111l3.888-3.891c0.346-0.347,0.391-0.894,0.104-1.292l-3.498-4.857 c0.593-1.08,1.064-2.222,1.407-3.408l5.918-1.039c0.479-0.084,0.827-0.5,0.827-0.985v-5.5C47.999,21.718,47.644,21.3,47.16,21.221z M25,32c-3.866,0-7-3.134-7-7c0-3.866,3.134-7,7-7s7,3.134,7,7C32,28.866,28.866,32,25,32z"></path> </svg>',
language: "de",
seoImage: "",
sections: [
{
order: 1,
title: templateKey("categories.gettingStartedDe.sections.basics.title"),
description: templateKey("categories.gettingStartedDe.sections.basics.description"),
},
],
},
{
knowledgeBaseSlug: "center",
slug: "kategorie-2",
order: 2,
title: templateKey("categories.accountManagementDe.title"),
description: templateKey("categories.accountManagementDe.description"),
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"> <path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" /> </svg>',
language: "de",
seoImage: "",
sections: [],
},
{
knowledgeBaseSlug: "center",
slug: "account-management",
order: 2,
title: templateKey("categories.accountManagement.title"),
description: templateKey("categories.accountManagement.description"),
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"> <path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" /> </svg>',
language: "en",
seoImage: "",
sections: [],
},
],
articles: [
{
knowledgeBaseSlug: "docs",
categorySlug: "introduction",
categorySectionOrder: null,
slug: "example-site-knowledge-base",
title: templateKey("articles.exampleSiteKnowledgeBase.title"),
description: templateKey("articles.exampleSiteKnowledgeBase.description"),
order: 0,
contentDraft: templateKey("articles.exampleSiteKnowledgeBase.content.markup"),
contentPublished: templateKey("articles.exampleSiteKnowledgeBase.content.markup"),
contentPublishedAsText: templateKey("articles.exampleSiteKnowledgeBase.content.text"),
contentType: "wysiwyg",
language: "en",
featuredOrder: 2,
seoImage: "",
publishedAt: "2023-07-01T23:42:31.371Z",
createdByUserEmail: null,
relatedArticles: [],
},
{
knowledgeBaseSlug: "docs",
categorySlug: "introduction",
categorySectionOrder: null,
slug: "publish-product-documentation",
title: templateKey("articles.publishProductDocumentation.title"),
description: templateKey("articles.publishProductDocumentation.description"),
order: 0,
contentDraft: templateKey("articles.publishProductDocumentation.content.markup"),
contentPublished: templateKey("articles.publishProductDocumentation.content.markup"),
contentPublishedAsText: templateKey("articles.publishProductDocumentation.content.text"),
contentType: "wysiwyg",
language: "en",
featuredOrder: 1,
seoImage: "",
publishedAt: "2023-07-01T21:49:40.834Z",
createdByUserEmail: null,
relatedArticles: [],
},
{
knowledgeBaseSlug: "center",
categorySlug: "kategorie-1",
categorySectionOrder: 1,
slug: "beispiel-2",
title: templateKey("articles.structureKnowledgeBaseDe.title"),
description: templateKey("articles.structureKnowledgeBaseDe.description"),
order: 0,
contentDraft: templateKey("articles.structureKnowledgeBaseDe.content.markup"),
contentPublished: templateKey("articles.structureKnowledgeBaseDe.content.markup"),
contentPublishedAsText: templateKey("articles.structureKnowledgeBaseDe.content.text"),
contentType: "wysiwyg",
language: "de",
featuredOrder: 3,
seoImage: "",
publishedAt: "2023-07-01T21:39:51.857Z",
createdByUserEmail: null,
relatedArticles: [],
},
{
knowledgeBaseSlug: "center",
categorySlug: "getting-started",
categorySectionOrder: null,
slug: "structure-your-help-center",
title: templateKey("articles.structureYourHelpCenter.title"),
description: templateKey("articles.structureYourHelpCenter.description"),
order: 0,
contentDraft: templateKey("articles.structureYourHelpCenter.content.markup"),
contentPublished: templateKey("articles.structureYourHelpCenter.content.markup"),
contentPublishedAsText: templateKey("articles.structureYourHelpCenter.content.text"),
contentType: "wysiwyg",
language: "en",
featuredOrder: 2,
seoImage: "",
publishedAt: "2023-07-01T23:35:32.524Z",
createdByUserEmail: null,
relatedArticles: [
{
slug: "welcome-to-the-help-center",
},
{
slug: "manage-team-access",
},
],
},
{
knowledgeBaseSlug: "center",
categorySlug: "getting-started",
categorySectionOrder: null,
slug: "welcome-to-the-help-center",
title: templateKey("articles.welcomeToTheHelpCenter.title"),
description: templateKey("articles.welcomeToTheHelpCenter.description"),
order: 0,
contentDraft: templateKey("articles.welcomeToTheHelpCenter.content.markup"),
contentPublished: templateKey("articles.welcomeToTheHelpCenter.content.markup"),
contentPublishedAsText: templateKey("articles.welcomeToTheHelpCenter.content.text"),
contentType: "wysiwyg",
language: "en",
featuredOrder: 1,
seoImage: "",
publishedAt: "2023-07-01T21:39:38.696Z",
createdByUserEmail: null,
relatedArticles: [],
},
{
knowledgeBaseSlug: "center",
categorySlug: "kategorie-1",
categorySectionOrder: null,
slug: "beispiel-1",
title: templateKey("articles.welcomeToKnowledgeBaseDe.title"),
description: templateKey("articles.welcomeToKnowledgeBaseDe.description"),
order: 0,
contentDraft: templateKey("articles.welcomeToKnowledgeBaseDe.content.markup"),
contentPublished: templateKey("articles.welcomeToKnowledgeBaseDe.content.markup"),
contentPublishedAsText: templateKey("articles.welcomeToKnowledgeBaseDe.content.text"),
contentType: "wysiwyg",
language: "de",
featuredOrder: 3,
seoImage: "",
publishedAt: "2023-07-01T21:39:49.570Z",
createdByUserEmail: null,
relatedArticles: [],
},
{
knowledgeBaseSlug: "center",
categorySlug: "kategorie-2",
categorySectionOrder: null,
slug: "beispiel-3",
title: templateKey("articles.manageTeamAccessDe.title"),
description: templateKey("articles.manageTeamAccessDe.description"),
order: 0,
contentDraft: templateKey("articles.manageTeamAccessDe.content.markup"),
contentPublished: templateKey("articles.manageTeamAccessDe.content.markup"),
contentPublishedAsText: templateKey("articles.manageTeamAccessDe.content.text"),
contentType: "wysiwyg",
language: "de",
featuredOrder: null,
seoImage: "",
publishedAt: "2023-07-01T21:39:47.550Z",
createdByUserEmail: null,
relatedArticles: [],
},
{
knowledgeBaseSlug: "center",
categorySlug: "account-management",
categorySectionOrder: null,
slug: "manage-team-access",
title: templateKey("articles.manageTeamAccess.title"),
description: templateKey("articles.manageTeamAccess.description"),
order: 0,
contentDraft: templateKey("articles.manageTeamAccess.content.markup"),
contentPublished: templateKey("articles.manageTeamAccess.content.markup"),
contentPublishedAsText: templateKey("articles.manageTeamAccess.content.text"),
contentType: "wysiwyg",
language: "en",
featuredOrder: null,
seoImage: "",
publishedAt: "2023-07-01T21:39:36.515Z",
createdByUserEmail: null,
relatedArticles: [],
},
],
};
function buildSample(language: SupportedTemplateLanguage): KnowledgeBasesTemplateDto {
const knowledgeBases = SAMPLE_SOURCE.knowledgeBases
.filter((kb) => kb.languages.includes(language))
.map((kb) => ({
...kb,
title: translateTemplateValue(language, kb.title),
description: translateTemplateValue(language, kb.description),
defaultLanguage: language,
languages: [language],
links: kb.links.map((link) => ({
...link,
name: translateTemplateValue(language, link.name),
})),
}));
const knowledgeBaseSlugs = new Set(knowledgeBases.map((kb) => kb.slug));
const categories = SAMPLE_SOURCE.categories
.filter((category) => category.language === language && knowledgeBaseSlugs.has(category.knowledgeBaseSlug))
.map((category) => ({
...category,
title: translateTemplateValue(language, category.title),
description: translateTemplateValue(language, category.description),
sections: category.sections.map((section) => ({
...section,
title: translateTemplateValue(language, section.title),
description: translateTemplateValue(language, section.description),
})),
}));
const categorySlugs = new Set(categories.map((category) => `${category.knowledgeBaseSlug}:${category.slug}`));
const articles = SAMPLE_SOURCE.articles
.filter(
(article) =>
article.language === language &&
knowledgeBaseSlugs.has(article.knowledgeBaseSlug) &&
(!article.categorySlug || categorySlugs.has(`${article.knowledgeBaseSlug}:${article.categorySlug}`))
)
.map((article) => ({
...article,
title: translateTemplateValue(language, article.title),
description: translateTemplateValue(language, article.description),
contentDraft: translateTemplateValue(language, article.contentDraft),
contentPublished: translateTemplateValue(language, article.contentPublished),
contentPublishedAsText: translateTemplateValue(language, article.contentPublishedAsText),
}));
return {
knowledgeBases,
categories,
articles,
};
}
const SAMPLE_EN = buildSample("en");
const SAMPLE_DE = buildSample("de");
const SAMPLE = SAMPLE_EN;
export default {
SAMPLE,
SAMPLE_DE,
SAMPLE_EN,
};
@@ -0,0 +1,162 @@
import type { TFunction } from "i18next";
import UrlUtils from "~/utils/app/UrlUtils";
import { KnowledgeBaseArticleWithDetails } from "../db/kbArticles.db.server";
import { KbCategorySectionDto } from "../dtos/KbCategorySectionDto";
import { KnowledgeBaseDto } from "../dtos/KnowledgeBaseDto";
import { KbCategoryDto } from "../dtos/KbCategoryDto";
import { KbArticleDto } from "../dtos/KbArticleDto";
const defaultLanguage = "de";
const supportedLanguages: { name: string; value: string; labelKey: string }[] = [
{ name: "Deutsch", value: "de", labelKey: "shared.locales.de" },
{ name: "English", value: "en", labelKey: "shared.locales.en" },
];
function getLanguageName(language: string, t?: TFunction) {
const supportedLanguage = supportedLanguages.find((l) => l.value === language);
if (!supportedLanguage) {
return "";
}
return t ? String(t(supportedLanguage.labelKey, { defaultValue: supportedLanguage.name })) : supportedLanguage.name;
}
function getSupportedLanguageOptions(t: TFunction): { name: string; value: string }[] {
return supportedLanguages.map((language) => ({
name: getLanguageName(language.value, t),
value: language.value,
}));
}
function getAvailableArticleSlug({ allArticles, initialSlug }: { allArticles: KnowledgeBaseArticleWithDetails[]; initialSlug: string }) {
let number = 1;
let slug = "";
const findExistingSlug = (slug: string) => {
return allArticles.find((p) => p.slug === slug);
};
do {
slug = `${initialSlug}-${number}`;
const existingWithSlug = findExistingSlug(slug);
if (!existingWithSlug) {
break;
}
// if (number > 10) {
// throw Error("Too many duplicates");
// }
number++;
} while (true);
let maxOrder = 0;
let firstOrder = 0;
if (allArticles.length > 0) {
maxOrder = Math.max(...allArticles.map((p) => p.order));
firstOrder = Math.min(...allArticles.map((p) => p.order));
}
return {
slug,
maxOrder,
firstOrder,
number,
};
}
function getKbUrl({ kb, params }: { kb: { basePath: string; slug: string }; params: { lang?: string } }) {
let url = UrlUtils.join(kb.basePath, kb.slug);
const foundLang = supportedLanguages.find((l) => l.value === params.lang);
if (params.lang && foundLang) {
return `${url}/${params.lang}`;
}
return `${url}`;
}
function getArticleUrl({ kb, article, params }: { kb: { slug: string; basePath: string }; article: { slug: string }; params: { lang?: string } }) {
let kbUrl = getKbUrl({ kb, params });
return `${kbUrl}/articles/${article.slug}`;
}
function getCategoryUrl({ kb, category, params }: { kb: { slug: string; basePath: string }; category: { slug: string }; params: { lang?: string } }) {
let kbUrl = getKbUrl({ kb, params });
return `${kbUrl}/categories/${category.slug}`;
}
function getCategoryArticlesBySections({ kb, category }: { kb: KnowledgeBaseDto; category: KbCategoryDto }) {
let sections: {
section: KbCategorySectionDto | null;
articles: {
order: number;
title: string;
description: string;
href: string;
section: string | null;
sectionId: string | null;
}[];
}[] = [];
category.articles.forEach((article) => {
const section = category.sections.find((item) => item.id === article.sectionId) ?? null;
const sectionIndex = sections.findIndex((item) => item.section?.id === section?.id);
if (sectionIndex === -1) {
sections.push({
section,
articles: [
{
...article,
section: section?.title ?? null,
},
],
});
} else {
sections[sectionIndex].articles.push({
...article,
section: section?.title ?? null,
});
}
});
sections.forEach((item) => {
item.articles = item.articles.sort((a, b) => {
if (a.order && b.order) {
return a.order - b.order;
}
return 0;
});
});
sections = sections.sort((a, b) => {
if (a.section?.order && b.section?.order) {
return a.section.order - b.section.order;
}
return -1;
});
return sections;
}
function fixDarkMode({ article }: { article: KbArticleDto }) {
const replaces = {
"rounded-sm bg-stone-100 p-5 font-mono font-medium text-stone-800": "rounded-sm bg-secondary text-secondary-foreground p-5 font-mono font-medium",
"rounded-sm bg-secondary p-5 font-mono font-medium": "rounded-sm bg-secondary text-secondary-foreground p-5 font-mono font-medium",
"rounded-md bg-secondary px-1.5 py-1 font-mono font-medium": "rounded-md bg-secondary text-secondary-foreground px-1.5 py-1 font-mono font-medium",
"rounded-md bg-stone-200 px-1.5 py-1 font-mono font-medium text-black":
"rounded-md bg-secondary px-1.5 py-1 font-mono font-medium text-secondary-foreground",
"text-stone-600 underline underline-offset-[3px] hover:text-stone-700 transition-colors cursor-pointer text-stone-600 underline underline-offset-[3px] hover:text-stone-700 transition-colors cursor-pointer":
"text-muted-foreground underline underline-offset-[3px] hover:text-foreground transition-colors cursor-pointer",
"underline underline-offset-[3px] hover:text-foreground transition-colors cursor-pointer":
"text-muted-foreground underline underline-offset-[3px] hover:text-foreground transition-colors cursor-pointer",
};
if (article.contentPublished) {
for (const [key, value] of Object.entries(replaces)) {
article.contentPublished = article.contentPublished.replaceAll(key, value);
}
}
}
export default {
defaultLanguage,
supportedLanguages,
getSupportedLanguageOptions,
getLanguageName,
getAvailableArticleSlug,
getKbUrl,
getArticleUrl,
getCategoryUrl,
getCategoryArticlesBySections,
fixDarkMode,
};
@@ -0,0 +1,151 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import DefaultKbsTemplate from "./DefaultKbsTemplate";
const sourcePath = path.resolve(process.cwd(), "apps/web/app/modules/knowledgeBase/utils/DefaultKbsTemplate.ts");
const enTranslationsPath = path.resolve(process.cwd(), "apps/web/public/locales/en/translations.json");
const deTranslationsPath = path.resolve(process.cwd(), "apps/web/public/locales/de/translations.json");
function readTranslations(localePath: string) {
return JSON.parse(fs.readFileSync(localePath, "utf8")) as {
knowledgeBase: {
defaultTemplates: {
sample: {
knowledgeBases: {
docs: { description: string; title: string };
center: { description: string; links: { backToSite: string }; title: string };
};
articles: {
exampleSiteKnowledgeBase: { description: string; content: { text: string } };
publishProductDocumentation: { description: string; content: { text: string } };
};
categories: {
introduction: { description: string };
kategorie1: { sections: { basics: { description: string } } };
};
};
};
};
};
}
describe("DefaultKbsTemplate content", () => {
it("keeps seeded knowledge-base copy free from placeholder junk and misspelled API names", () => {
const source = fs.readFileSync(sourcePath, "utf8");
expect(source).toContain("knowledgeBase.defaultTemplates.sample");
expect(source).not.toContain("knowlegeBase");
expect(source).not.toContain("asdf");
expect(source).not.toContain("Sample description");
expect(source).not.toContain("Category 1");
expect(source).not.toContain("Introduction category description sample");
expect(source).not.toContain("Example Site - Knowledge Base");
expect(source).not.toContain("loom.com");
expect(source).not.toContain("demo@toolctrl.local");
expect(source).not.toContain("Dynamic vs Static Knowledge Bases");
expect(source).not.toContain("apps/web/app/routes/docs/($lang).tsx");
expect(source).not.toContain("related articles to guide readers");
});
it("stores semantic locale keys for default knowledge-base seed copy", () => {
const source = fs.readFileSync(sourcePath, "utf8");
const en = readTranslations(enTranslationsPath);
const de = readTranslations(deTranslationsPath);
expect(source).toContain('templateKey("knowledgeBases.docs.title")');
expect(source).toContain('templateKey("articles.publishProductDocumentation.content.text")');
expect(source).not.toContain("ui.autogenerated");
expect(en.knowledgeBase.defaultTemplates.sample.knowledgeBases.docs.title).toBe("Docs");
expect(en.knowledgeBase.defaultTemplates.sample.articles.publishProductDocumentation.content.text).toContain("related articles");
expect(de.knowledgeBase.defaultTemplates.sample.knowledgeBases.center.links.backToSite).toBe("Zur Website");
expect(de.knowledgeBase.defaultTemplates.sample.articles.publishProductDocumentation.content.text).toContain("verwandte Artikel");
});
it("exposes locale-scoped admin import samples instead of mixed public chrome", () => {
for (const [template, language] of [
[DefaultKbsTemplate.SAMPLE_EN, "en"],
[DefaultKbsTemplate.SAMPLE_DE, "de"],
] as const) {
expect(Object.keys(template).sort()).toEqual(["articles", "categories", "knowledgeBases"]);
expect(template.knowledgeBases.every((kb) => kb.defaultLanguage === language)).toBe(true);
expect(template.knowledgeBases.every((kb) => kb.languages.length === 1 && kb.languages[0] === language)).toBe(true);
expect(template.categories.every((category) => category.language === language)).toBe(true);
expect(template.articles.every((article) => article.language === language)).toBe(true);
}
expect(DefaultKbsTemplate.SAMPLE_DE.knowledgeBases.map((kb) => kb.title)).toContain("Hilfe-Center");
expect(JSON.stringify(DefaultKbsTemplate.SAMPLE_DE)).not.toContain("Help Center");
expect(JSON.stringify(DefaultKbsTemplate.SAMPLE_DE)).not.toContain("Back to site");
});
it("keeps German knowledge-base copy generic while preserving the Hilfe-Center product title", () => {
const deSample = DefaultKbsTemplate.SAMPLE_DE;
const deCopy = JSON.stringify(deSample);
expect(deSample.knowledgeBases.find((kb) => kb.slug === "center")?.title).toBe("Hilfe-Center");
expect(deSample.categories.find((category) => category.slug === "kategorie-1")?.description).toBe(
"Grundlagen für die Einrichtung und Nutzung der Wissensdatenbank."
);
expect(deSample.articles.find((article) => article.slug === "beispiel-2")?.title).toBe("Wissensdatenbank strukturieren");
expect(deSample.articles.find((article) => article.slug === "beispiel-1")?.contentPublishedAsText).toContain("Diese Wissensdatenbank bündelt");
expect(deCopy).not.toContain("Nutzung des Hilfe-Centers");
expect(deCopy).not.toContain("Dieses Hilfe-Center bündelt");
});
it("keeps default category and article metadata useful for imported starter content", () => {
for (const template of [DefaultKbsTemplate.SAMPLE_EN, DefaultKbsTemplate.SAMPLE_DE]) {
for (const knowledgeBase of template.knowledgeBases) {
expect(knowledgeBase.title.trim()).not.toHaveLength(0);
expect(knowledgeBase.description.trim()).not.toHaveLength(0);
}
for (const category of template.categories) {
expect(category.title.trim()).not.toHaveLength(0);
expect(category.description.trim()).not.toHaveLength(0);
for (const section of category.sections) {
expect(section.title.trim()).not.toHaveLength(0);
expect(section.description.trim()).not.toHaveLength(0);
}
}
for (const article of template.articles) {
expect(article.title.trim()).not.toHaveLength(0);
expect(article.description.trim()).not.toHaveLength(0);
}
}
expect(DefaultKbsTemplate.SAMPLE_EN.knowledgeBases.find((kb) => kb.slug === "docs")?.description).toBe(
"Public product documentation and starter guides for your team."
);
expect(DefaultKbsTemplate.SAMPLE_EN.knowledgeBases.find((kb) => kb.slug === "center")?.description).toBe(
"Customer-facing help articles, setup guidance, and account support."
);
expect(
DefaultKbsTemplate.SAMPLE_DE.categories.find((category) => category.slug === "kategorie-1")?.sections.find((section) => section.order === 1)
?.description
).toBe("Wichtige Einrichtungshinweise, um hilfreiche Startinhalte zu strukturieren.");
expect(DefaultKbsTemplate.SAMPLE_EN.categories.find((category) => category.slug === "introduction")?.description).toBe(
"Start here for an overview of the documentation structure."
);
const starterArticle = DefaultKbsTemplate.SAMPLE_EN.articles.find((article) => article.slug === "example-site-knowledge-base");
expect(starterArticle?.description).toBe("A short starter article for the public docs knowledge base.");
expect(starterArticle?.contentPublishedAsText).toContain("neutral starting point");
expect(starterArticle?.createdByUserEmail).toBeNull();
const publishingArticle = DefaultKbsTemplate.SAMPLE_EN.articles.find((article) => article.slug === "publish-product-documentation");
expect(publishingArticle?.description).toBe("A practical checklist for turning draft articles into useful public documentation.");
expect(publishingArticle?.contentPublishedAsText).toContain("answers a real user question");
expect(publishingArticle?.contentPublishedAsText).not.toContain("route files");
});
it("keeps imported sample articles self-contained and attributed to the importing admin", () => {
for (const template of [DefaultKbsTemplate.SAMPLE_EN, DefaultKbsTemplate.SAMPLE_DE]) {
const serialized = JSON.stringify(template);
expect(serialized).not.toContain("loom.com");
expect(serialized).not.toContain("demo@toolctrl.local");
expect(template.articles.every((article) => article.createdByUserEmail === null)).toBe(true);
}
});
});