add a public URL per help center and fix code review issues

Each help center gets an optional public_url. When set, canonical links, hreflang
alternates, og:url, the sitemap and robots.txt point at that host instead of the
app root URL. Stored media URLs are rewritten to root-relative paths so logos,
favicons, header images and article images resolve on whichever host serves the
page. The URL is validated on both the form and the backend, and trailing slashes
are trimmed.

Also fixes issues found in code review:

- the help center template used a locale link as the data root inside the
  alternates loop, which broke every public page on a help center with two or
  more locales
- creating an article posted the clicked collection id, so changing the
  collection in the sheet filed the article in the wrong place
- the collection field's error message was hidden when the locale had no
  collections, so Create did nothing with no error shown
- switching the header type or unticking popular articles dropped those theme
  values on save, because vee-validate unsets fields that unmount
- closing a new collection sheet with Esc left the parent id set, so the next
  collection you edited got re-parented
- a table row with th labels in the first column was treated as a header row
  when preparing article content for embeddings
- the img tag regex stopped at a > inside an attribute value and injected
  loading/decoding in the middle of an attribute
This commit is contained in:
Abhinav Raut
2026-08-05 01:10:31 +05:30
parent b4b4251ebd
commit 353558ce9b
14 changed files with 192 additions and 117 deletions
+54 -18
View File
@@ -18,6 +18,7 @@ import (
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/helpcenter"
hcmodels "github.com/abhinavxd/libredesk/internal/helpcenter/models"
"github.com/abhinavxd/libredesk/internal/media"
"github.com/abhinavxd/libredesk/internal/stringutil"
realip "github.com/ferluci/fast-realip"
"github.com/knadh/stuffbin"
@@ -211,7 +212,7 @@ func handleHelpCenterPreview(r *fastglue.Request) error {
"Data": map[string]interface{}{
"Title": helpCenter.PageTitle,
"LandingHero": true,
"HelpCenter": helpCenterTemplateData(helpCenter, locale),
"HelpCenter": helpCenterTemplateData(app, helpCenter, locale),
"Tree": tree.Tree,
"Popular": popular,
},
@@ -593,12 +594,12 @@ func handleShowHelpCenterHome(r *fastglue.Request) error {
popular = nil
}
var (
root = helpCenterRootURL(app)
root = helpCenterBaseURL(app, helpCenter)
locales = helpCenterLocales(helpCenter)
pathFor = func(l string) string { return helpCenterHomePath(helpCenter.Slug, l) }
metaDescription = firstNonEmpty(tree.HelpCenter.MetaDescription, tree.HelpCenter.HeaderText)
)
data := helpCenterTemplateData(tree.HelpCenter, locale)
data := helpCenterTemplateData(app, tree.HelpCenter, locale)
return renderHelpCenterPage(r, "help-center", map[string]interface{}{
"L": localeI18n(app, locale),
"Data": map[string]interface{}{
@@ -606,7 +607,7 @@ func handleShowHelpCenterHome(r *fastglue.Request) error {
"MetaDescription": metaDescription,
"CanonicalPath": pathFor(locale),
"LandingHero": true,
"OGImage": absoluteURL(root, tree.HelpCenter.LogoURL),
"OGImage": absoluteURL(root, publicAssetPaths(app, tree.HelpCenter.LogoURL)),
"Alternates": helpCenterAlternates(helpCenter, locales, pathFor),
"XDefaultPath": defaultLocalePath(helpCenter, locales, pathFor),
"LocaleLinks": helpCenterLocaleLinks(helpCenter, locales, pathFor),
@@ -646,17 +647,17 @@ func handleShowHelpCenterCollection(r *fastglue.Request) error {
translated = []string{locale}
}
var (
root = helpCenterRootURL(app)
root = helpCenterBaseURL(app, helpCenter)
pathFor = func(l string) string { return collectionPath(helpCenter.Slug, l, collection.Slug) }
)
data := helpCenterTemplateData(helpCenter, locale)
data := helpCenterTemplateData(app, helpCenter, locale)
return renderHelpCenterPage(r, "help-collection", map[string]interface{}{
"L": localeI18n(app, locale),
"Data": map[string]interface{}{
"Title": fmt.Sprintf("%s - %s", collection.Name, helpCenter.Name),
"MetaDescription": collection.Description,
"CanonicalPath": pathFor(locale),
"OGImage": absoluteURL(root, helpCenter.LogoURL),
"OGImage": absoluteURL(root, publicAssetPaths(app, helpCenter.LogoURL)),
"Alternates": helpCenterAlternates(helpCenter, translated, pathFor),
"XDefaultPath": defaultLocalePath(helpCenter, translated, pathFor),
"LocaleLinks": helpCenterLocaleLinks(helpCenter, translated, pathFor),
@@ -713,13 +714,13 @@ func handleShowHelpCenterArticle(r *fastglue.Request) error {
translated = []string{locale}
}
var (
root = helpCenterRootURL(app)
root = helpCenterBaseURL(app, helpCenter)
pathFor = func(l string) string { return articlePath(helpCenter.Slug, l, article.Slug) }
metaDescription = firstNonEmpty(article.MetaDescription, article.Excerpt)
metaTitle = firstNonEmpty(article.MetaTitle, fmt.Sprintf("%s - %s", article.Title, helpCenter.Name))
ogImage = absoluteURL(root, firstNonEmpty(article.MetaImageURL, helpCenter.LogoURL))
ogImage = absoluteURL(root, publicAssetPaths(app, firstNonEmpty(article.MetaImageURL, helpCenter.LogoURL)))
)
data := helpCenterTemplateData(helpCenter, locale)
data := helpCenterTemplateData(app, helpCenter, locale)
return renderHelpCenterPage(r, "help-article", map[string]interface{}{
"L": localeI18n(app, locale),
"Data": map[string]interface{}{
@@ -739,7 +740,7 @@ func handleShowHelpCenterArticle(r *fastglue.Request) error {
"AuthorInitial": authorInitial(article),
"Collection": collection,
"Related": related,
"Content": template.HTML(stringutil.DeferOffscreenImages(article.Content)),
"Content": template.HTML(publicAssetPaths(app, stringutil.DeferOffscreenImages(article.Content))),
},
})
}
@@ -769,7 +770,7 @@ func handleHelpCenterSearch(r *fastglue.Request) error {
}
}
var (
data = helpCenterTemplateData(helpCenter, locale)
data = helpCenterTemplateData(app, helpCenter, locale)
lcl = localeI18n(app, locale)
pathFor = func(l string) string { return searchPath(helpCenter.Slug, l) }
)
@@ -813,7 +814,7 @@ func handleHelpCenterSitemap(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
root := helpCenterRootURL(app)
root := helpCenterBaseURL(app, helpCenter)
set := urlset{Xmlns: sitemapNamespace}
set.URLs = append(set.URLs, sitemapURL{
Loc: root + helpCenterHomePath(helpCenter.Slug, locale),
@@ -841,9 +842,9 @@ func handleSitemapIndex(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
root := helpCenterRootURL(app)
index := sitemapIndex{Xmlns: sitemapNamespace}
for _, hc := range helpCenters {
root := helpCenterBaseURL(app, hc)
for _, locale := range helpCenterLocales(hc) {
index.Sitemaps = append(index.Sitemaps, sitemapRef{Loc: fmt.Sprintf("%s%s/sitemap.xml", root, helpCenterHomePath(hc.Slug, locale))})
}
@@ -860,6 +861,19 @@ func handleRobotsTxt(r *fastglue.Request) error {
fmt.Fprintf(r.RequestCtx, "Disallow: %s\n", path)
}
fmt.Fprintf(r.RequestCtx, "\nSitemap: %s/sitemap.xml\n", helpCenterRootURL(app))
// A help center on its own host can't be crawled through the app root's sitemap index.
helpCenters, err := app.helpcenter.GetActiveHelpCenters()
if err != nil {
return nil
}
for _, hc := range helpCenters {
if hc.PublicURL == "" {
continue
}
for _, locale := range helpCenterLocales(hc) {
fmt.Fprintf(r.RequestCtx, "Sitemap: %s%s/sitemap.xml\n", hc.PublicURL, helpCenterHomePath(hc.Slug, locale))
}
}
return nil
}
@@ -1055,6 +1069,25 @@ func helpCenterRootURL(app *App) string {
return strings.TrimRight(app.consts.Load().(*constants).AppBaseURL, "/")
}
// helpCenterBaseURL returns the host a reader sees this help center on: its own public URL when
// set, else the app root URL.
func helpCenterBaseURL(app *App, hc hcmodels.HelpCenter) string {
if hc.PublicURL != "" {
return hc.PublicURL
}
return helpCenterRootURL(app)
}
// publicAssetPaths rewrites stored absolute media URLs to root-relative ones so uploads resolve
// on whichever host serves the page.
func publicAssetPaths(app *App, s string) string {
root := helpCenterRootURL(app)
if root == "" {
return s
}
return strings.ReplaceAll(s, root+media.PublicURI, media.PublicURI)
}
// absoluteURL resolves a root-relative URL against the app root URL, since social and
// structured-data consumers reject relative image URLs.
func absoluteURL(root, u string) string {
@@ -1305,7 +1338,7 @@ func helpCenterLocales(hc hcmodels.HelpCenter) []string {
}
// helpCenterTemplateData shapes a help center row for the public templates.
func helpCenterTemplateData(hc hcmodels.HelpCenter, locale string) map[string]interface{} {
func helpCenterTemplateData(app *App, hc hcmodels.HelpCenter, locale string) map[string]interface{} {
navLinks := []hcmodels.NavLink{}
if len(hc.NavLinks) > 0 {
if err := json.Unmarshal(hc.NavLinks, &navLinks); err != nil {
@@ -1318,12 +1351,15 @@ func helpCenterTemplateData(hc hcmodels.HelpCenter, locale string) map[string]in
theme = hcmodels.DefaultTheme()
}
}
theme.Favicon = publicAssetPaths(app, theme.Favicon)
theme.Header.BackgroundImage = publicAssetPaths(app, theme.Header.BackgroundImage)
return map[string]interface{}{
"Slug": hc.Slug,
"Name": hc.Name,
"BaseURL": helpCenterBaseURL(app, hc),
"PageTitle": hc.PageTitle,
"HeaderText": hc.HeaderText,
"LogoURL": hc.LogoURL,
"LogoURL": publicAssetPaths(app, hc.LogoURL),
"Color": hc.Color,
"DefaultLocale": hc.DefaultLocale,
"CurrentLocale": locale,
@@ -1401,7 +1437,7 @@ func renderHelpCenterNotFound(r *fastglue.Request, hc *hcmodels.HelpCenter) erro
if !ok {
locale = helpCenter.DefaultLocale
}
data := helpCenterTemplateData(helpCenter, locale)
data := helpCenterTemplateData(app, helpCenter, locale)
lcl := localeI18n(app, locale)
r.RequestCtx.Response.Header.Set("X-Robots-Tag", noIndexHeader)
rerr := app.tmpl.RenderWebPage(r.RequestCtx, "help-notfound", map[string]interface{}{
@@ -1570,7 +1606,7 @@ func renderHelpCenterArticlePreview(r *fastglue.Request, helpCenter hcmodels.Hel
"Data": map[string]interface{}{
"Title": article.Title,
"ModifiedTime": article.UpdatedAt.Format(time.RFC3339),
"HelpCenter": helpCenterTemplateData(helpCenter, locale),
"HelpCenter": helpCenterTemplateData(app, helpCenter, locale),
"Article": article,
"AuthorInitial": authorInitial(article),
"Collection": collection,
@@ -123,13 +123,9 @@
{{ t('helpCenter.noCollectionsInLanguage') }}
</p>
<FormField
v-if="localeCollections.length > 0"
v-slot="{ componentField }"
name="collection_id"
>
<FormField v-slot="{ componentField }" name="collection_id">
<FormItem>
<FormControl>
<FormControl v-if="localeCollections.length > 0">
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue>{{ collectionLabel }}</SelectValue>
@@ -29,6 +29,17 @@
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="public_url">
<FormItem>
<FormLabel>{{ t('helpCenter.publicURL') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="https://help.example.com" v-bind="componentField" />
</FormControl>
<FormDescription>{{ t('helpCenter.publicURLHint') }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="page_title">
<FormItem>
<FormLabel>{{ t('helpCenter.pageTitle') }}</FormLabel>
@@ -209,21 +220,19 @@
</FormItem>
</FormField>
<FormField
v-if="form.values.theme?.header?.background_type === 'solid'"
v-slot="{ componentField }"
name="theme.header.background_color"
>
<FormItem>
<FormLabel>{{ t('globals.messages.backgroundColor') }}</FormLabel>
<FormControl>
<Input type="color" v-bind="componentField" />
</FormControl>
</FormItem>
</FormField>
<div v-show="form.values.theme?.header?.background_type === 'solid'">
<FormField v-slot="{ componentField }" name="theme.header.background_color">
<FormItem>
<FormLabel>{{ t('globals.messages.backgroundColor') }}</FormLabel>
<FormControl>
<Input type="color" v-bind="componentField" />
</FormControl>
</FormItem>
</FormField>
</div>
<div
v-if="form.values.theme?.header?.background_type === 'gradient'"
v-show="form.values.theme?.header?.background_type === 'gradient'"
class="flex gap-4"
>
<FormField v-slot="{ componentField }" name="theme.header.gradient_from">
@@ -244,24 +253,22 @@
</FormField>
</div>
<FormField
v-if="form.values.theme?.header?.background_type === 'image'"
v-slot="{ componentField }"
name="theme.header.background_image"
>
<FormItem>
<FormLabel>{{ t('helpCenter.styling.headerImage') }}</FormLabel>
<FormControl>
<Input
type="text"
placeholder="https://example.com/header.jpg"
v-bind="componentField"
/>
</FormControl>
<FormDescription>{{ t('helpCenter.styling.headerImageHint') }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<div v-show="form.values.theme?.header?.background_type === 'image'">
<FormField v-slot="{ componentField }" name="theme.header.background_image">
<FormItem>
<FormLabel>{{ t('helpCenter.styling.headerImage') }}</FormLabel>
<FormControl>
<Input
type="text"
placeholder="https://example.com/header.jpg"
v-bind="componentField"
/>
</FormControl>
<FormDescription>{{ t('helpCenter.styling.headerImageHint') }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</div>
<FormField v-slot="{ componentField }" name="theme.header.text_color">
<FormItem>
@@ -338,26 +345,24 @@
</FormItem>
</FormField>
<FormField
v-if="form.values.theme?.layout?.collections !== 'list'"
v-slot="{ componentField }"
name="theme.layout.columns"
>
<FormItem>
<FormLabel>{{ t('helpCenter.styling.cardsPerRow') }}</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
</SelectContent>
</Select>
</FormControl>
</FormItem>
</FormField>
<div v-show="form.values.theme?.layout?.collections !== 'list'">
<FormField v-slot="{ componentField }" name="theme.layout.columns">
<FormItem>
<FormLabel>{{ t('helpCenter.styling.cardsPerRow') }}</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
</SelectContent>
</Select>
</FormControl>
</FormItem>
</FormField>
</div>
<FormField v-slot="{ componentField }" name="theme.cards.icon_position">
<FormItem>
@@ -393,23 +398,21 @@
}}</FormLabel>
</FormItem>
</FormField>
<FormField
v-if="form.values.theme?.layout?.show_popular_articles"
v-slot="{ componentField }"
name="theme.layout.popular_articles_label"
>
<FormItem>
<FormLabel>{{ t('helpCenter.styling.popularArticlesLabel') }}</FormLabel>
<FormControl>
<Input
type="text"
:placeholder="t('helpCenter.popularArticles')"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<div v-show="form.values.theme?.layout?.show_popular_articles">
<FormField v-slot="{ componentField }" name="theme.layout.popular_articles_label">
<FormItem>
<FormLabel>{{ t('helpCenter.styling.popularArticlesLabel') }}</FormLabel>
<FormControl>
<Input
type="text"
:placeholder="t('helpCenter.popularArticles')"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<FormField v-slot="{ value, handleChange }" name="theme.cards.hide_description">
<FormItem class="flex items-center gap-2 space-y-0">
<FormControl>
@@ -698,6 +701,7 @@ const socialPlatforms = [
const toFormValues = (hc) => ({
name: hc?.name || '',
slug: hc?.slug || '',
public_url: hc?.public_url || '',
page_title: hc?.page_title || '',
header_text: hc?.header_text || '',
meta_description: hc?.meta_description || '',
@@ -39,6 +39,10 @@ export const createHelpCenterFormSchema = (t) => {
logo_url: optionalURL,
color: z.string().optional(),
nav_links: linkArray,
public_url: z
.string()
.refine((v) => !v || /^https?:\/\/[^"'()\s\\<>;{}]+$/.test(v), t('helpCenter.invalidPublicURL'))
.optional(),
custom_css: z.string().optional(),
custom_js: z.string().optional(),
default_locale: z
@@ -119,7 +119,7 @@
<ArticleEditSheet
:is-open="showArticleEditSheet"
@update:open="showArticleEditSheet = $event"
@update:open="$event ? (showArticleEditSheet = true) : closeEditSheet()"
:article="editingArticle"
:collection-id="editingArticle?.collection_id || createArticleCollectionId"
:help-center-id="parseInt(id)"
@@ -133,7 +133,7 @@
<CollectionEditSheet
:is-open="showCollectionEditSheet"
@update:open="showCollectionEditSheet = $event"
@update:open="$event ? (showCollectionEditSheet = true) : closeEditSheet()"
:collection="editingCollection"
:help-center-id="parseInt(id)"
:parent-id="createCollectionParentId"
@@ -420,9 +420,7 @@ const closeEditSheet = () => {
const visitSite = () => {
const rootUrl = appSettingsStore.settings?.['app.root_url'] || window.location.origin
const locale = props.locale || helpCenter.value?.default_locale || ''
const path = locale
? `/hc/${helpCenter.value?.slug}/${locale}`
: `/hc/${helpCenter.value?.slug}`
const path = locale ? `/hc/${helpCenter.value?.slug}/${locale}` : `/hc/${helpCenter.value?.slug}`
window.open(`${rootUrl.replace(/\/$/, '')}${path}`, '_blank', 'noopener')
}
@@ -507,7 +505,7 @@ const handleArticleSave = async (formData) => {
if (editingArticle.value) {
await api.updateArticle(editingArticle.value.id, formData)
} else {
await api.createArticle(createArticleCollectionId.value, formData)
await api.createArticle(formData.collection_id || createArticleCollectionId.value, formData)
}
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.savedSuccessfully')
+3
View File
@@ -1261,6 +1261,9 @@
"helpCenter.searchPlaceholder": "Search articles...",
"helpCenter.searchResults": "Results for",
"helpCenter.slugHint": "Used in the help center URL.",
"helpCenter.publicURL": "Public URL (optional)",
"helpCenter.publicURLHint": "If you want to serve this help center on its own domain, enter it here so canonical links, sitemaps and share previews point there instead of the app's root URL. That domain also needs to point at Libredesk in your DNS and reverse proxy.",
"helpCenter.invalidPublicURL": "Enter a full URL starting with https://, with no trailing slash.",
"imageLightbox.next": "Next image",
"imageLightbox.previous": "Previous image",
"imageLightbox.resetZoom": "Reset zoom",
+23 -2
View File
@@ -7,6 +7,7 @@ import (
"encoding/json"
"fmt"
"maps"
"net/url"
"regexp"
"slices"
"strings"
@@ -97,6 +98,7 @@ type HelpCenterRequest struct {
DefaultLocale string `json:"default_locale"`
AllowedLocales json.RawMessage `json:"allowed_locales"`
Theme json.RawMessage `json:"theme"`
PublicURL string `json:"public_url"`
}
type CollectionRequest struct {
@@ -271,7 +273,10 @@ func (m *Manager) CreateHelpCenter(req HelpCenterRequest) (models.HelpCenter, er
if err := m.validateColor(req.Color); err != nil {
return hc, err
}
if err := m.q.InsertHelpCenter.Get(&hc, req.Name, req.Slug, req.PageTitle, req.HeaderText, req.MetaDescription, req.LogoURL, req.Color, req.NavLinks, req.CustomCSS, req.CustomJS, req.DefaultLocale, req.AllowedLocales, req.Theme); err != nil {
if err := m.validatePublicURL(req.PublicURL); err != nil {
return hc, err
}
if err := m.q.InsertHelpCenter.Get(&hc, req.Name, req.Slug, req.PageTitle, req.HeaderText, req.MetaDescription, req.LogoURL, req.Color, req.NavLinks, req.CustomCSS, req.CustomJS, req.DefaultLocale, req.AllowedLocales, req.Theme, req.PublicURL); err != nil {
if dbutil.IsUniqueViolationError(err) {
return hc, envelope.NewError(envelope.ConflictError, m.i18n.T("globals.messages.errorAlreadyExists"), nil)
}
@@ -300,6 +305,7 @@ func (m *Manager) DraftHelpCenter(id int, req HelpCenterRequest) (models.HelpCen
hc.DefaultLocale = req.DefaultLocale
hc.AllowedLocales = req.AllowedLocales
hc.Theme = req.Theme
hc.PublicURL = req.PublicURL
if err := m.validateColor(hc.Color); err != nil {
return hc, err
}
@@ -319,7 +325,10 @@ func (m *Manager) UpdateHelpCenter(id int, req HelpCenterRequest) (models.HelpCe
if err := m.validateColor(req.Color); err != nil {
return hc, err
}
if err := m.q.UpdateHelpCenter.Get(&hc, id, req.Name, req.Slug, req.PageTitle, req.HeaderText, req.MetaDescription, req.LogoURL, req.Color, req.NavLinks, req.CustomCSS, req.CustomJS, req.DefaultLocale, req.AllowedLocales, req.Theme); err != nil {
if err := m.validatePublicURL(req.PublicURL); err != nil {
return hc, err
}
if err := m.q.UpdateHelpCenter.Get(&hc, id, req.Name, req.Slug, req.PageTitle, req.HeaderText, req.MetaDescription, req.LogoURL, req.Color, req.NavLinks, req.CustomCSS, req.CustomJS, req.DefaultLocale, req.AllowedLocales, req.Theme, req.PublicURL); err != nil {
if dbutil.IsUniqueViolationError(err) {
return hc, envelope.NewError(envelope.ConflictError, m.i18n.T("globals.messages.errorAlreadyExists"), nil)
}
@@ -1208,6 +1217,17 @@ func (m *Manager) validateLocales(defaultLocale string, allowed json.RawMessage)
return nil
}
func (m *Manager) validatePublicURL(publicURL string) error {
if publicURL == "" {
return nil
}
u, err := url.Parse(publicURL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return envelope.NewError(envelope.InputError, m.i18n.T("helpCenter.invalidPublicURL"), nil)
}
return nil
}
func (m *Manager) validateColor(color string) error {
if !hexColorRe.MatchString(color) {
return envelope.NewError(envelope.InputError, m.i18n.T("helpCenter.invalidColor"), nil)
@@ -1243,6 +1263,7 @@ func normalizeHelpCenterRequest(req HelpCenterRequest) HelpCenterRequest {
if req.Color == "" {
req.Color = defaultAccentColor
}
req.PublicURL = strings.TrimRight(strings.TrimSpace(req.PublicURL), "/")
req.LogoURL = sanitizeAssetURL(req.LogoURL)
req.NavLinks = normalizeNavLinks(req.NavLinks)
+1
View File
@@ -29,6 +29,7 @@ type HelpCenter struct {
AllowedLocales json.RawMessage `db:"allowed_locales" json:"allowed_locales"`
IsActive bool `db:"is_active" json:"is_active"`
Theme json.RawMessage `db:"theme" json:"theme"`
PublicURL string `db:"public_url" json:"public_url"`
}
// Theme holds the customizable branding for a help center's public pages.
+7 -7
View File
@@ -1,32 +1,32 @@
-- name: get-all-help-centers
SELECT id, created_at, updated_at, name, slug, page_title, header_text, meta_description, logo_url, color, nav_links, custom_css, custom_js, default_locale, allowed_locales, is_active, theme
SELECT id, created_at, updated_at, name, slug, page_title, header_text, meta_description, logo_url, color, nav_links, custom_css, custom_js, default_locale, allowed_locales, is_active, theme, public_url
FROM help_centers
ORDER BY created_at DESC;
-- name: get-active-help-centers
SELECT id, created_at, updated_at, name, slug, page_title, header_text, meta_description, logo_url, color, nav_links, custom_css, custom_js, default_locale, allowed_locales, is_active, theme
SELECT id, created_at, updated_at, name, slug, page_title, header_text, meta_description, logo_url, color, nav_links, custom_css, custom_js, default_locale, allowed_locales, is_active, theme, public_url
FROM help_centers
WHERE is_active = true
ORDER BY created_at DESC;
-- name: get-help-center-by-id
SELECT id, created_at, updated_at, name, slug, page_title, header_text, meta_description, logo_url, color, nav_links, custom_css, custom_js, default_locale, allowed_locales, is_active, theme
SELECT id, created_at, updated_at, name, slug, page_title, header_text, meta_description, logo_url, color, nav_links, custom_css, custom_js, default_locale, allowed_locales, is_active, theme, public_url
FROM help_centers
WHERE id = $1;
-- name: get-help-center-by-slug
SELECT id, created_at, updated_at, name, slug, page_title, header_text, meta_description, logo_url, color, nav_links, custom_css, custom_js, default_locale, allowed_locales, is_active, theme
SELECT id, created_at, updated_at, name, slug, page_title, header_text, meta_description, logo_url, color, nav_links, custom_css, custom_js, default_locale, allowed_locales, is_active, theme, public_url
FROM help_centers
WHERE slug = $1 AND is_active = true;
-- name: insert-help-center
INSERT INTO help_centers (name, slug, page_title, header_text, meta_description, logo_url, color, nav_links, custom_css, custom_js, default_locale, allowed_locales, theme)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
INSERT INTO help_centers (name, slug, page_title, header_text, meta_description, logo_url, color, nav_links, custom_css, custom_js, default_locale, allowed_locales, theme, public_url)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING *;
-- name: update-help-center
UPDATE help_centers
SET name = $2, slug = $3, page_title = $4, header_text = $5, meta_description = $6, logo_url = $7, color = $8, nav_links = $9, custom_css = $10, custom_js = $11, default_locale = $12, allowed_locales = $13, theme = $14, updated_at = NOW()
SET name = $2, slug = $3, page_title = $4, header_text = $5, meta_description = $6, logo_url = $7, color = $8, nav_links = $9, custom_css = $10, custom_js = $11, default_locale = $12, allowed_locales = $13, theme = $14, public_url = $15, updated_at = NOW()
WHERE id = $1
RETURNING *;
+5 -1
View File
@@ -25,7 +25,8 @@ func V2_7_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
default_locale TEXT NOT NULL DEFAULT 'en',
allowed_locales JSONB NOT NULL DEFAULT '["en"]',
is_active BOOLEAN NOT NULL DEFAULT true,
theme JSONB NOT NULL DEFAULT '{}'
theme JSONB NOT NULL DEFAULT '{}',
public_url TEXT NOT NULL DEFAULT ''
);
`); err != nil {
return err
@@ -42,6 +43,9 @@ func V2_7_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
if _, err := db.Exec(`ALTER TABLE help_centers ADD COLUMN IF NOT EXISTS meta_description TEXT NOT NULL DEFAULT '';`); err != nil {
return err
}
if _, err := db.Exec(`ALTER TABLE help_centers ADD COLUMN IF NOT EXISTS public_url TEXT NOT NULL DEFAULT '';`); err != nil {
return err
}
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS article_collections (
+10 -3
View File
@@ -138,10 +138,17 @@ func captionText(table *html.Node) string {
}
func isHeaderRow(row *html.Node) bool {
seen := false
for c := row.FirstChild; c != nil; c = c.NextSibling {
if c.Type == html.ElementNode && c.Data == "th" {
return true
if c.Type != html.ElementNode {
continue
}
switch c.Data {
case "th":
seen = true
case "td":
return false
}
}
return false
return seen
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
)
var (
imgTagRe = regexp.MustCompile(`(?i)<img\b[^>]*>`)
imgTagRe = regexp.MustCompile(`(?is)<img\b(?:"[^"]*"|'[^']*'|[^>"'])*>`)
imgLoadingAttrRe = regexp.MustCompile(`(?i)\bloading\s*=`)
+2 -1
View File
@@ -650,7 +650,8 @@ CREATE TABLE help_centers (
default_locale TEXT NOT NULL DEFAULT 'en',
allowed_locales JSONB NOT NULL DEFAULT '["en"]',
is_active BOOLEAN NOT NULL DEFAULT true,
theme JSONB NOT NULL DEFAULT '{}'
theme JSONB NOT NULL DEFAULT '{}',
public_url TEXT NOT NULL DEFAULT ''
);
DROP TABLE IF EXISTS article_collections CASCADE;
+4 -4
View File
@@ -7,15 +7,15 @@
<title>{{ .Data.Title }}</title>
{{ if .Data.MetaDescription }}<meta name="description" content="{{ .Data.MetaDescription }}" />{{ end }}
{{ if .Data.NoIndex }}<meta name="robots" content="noindex" />{{ end }}
{{ if .Data.CanonicalPath }}<link rel="canonical" href="{{ RootURL }}{{ .Data.CanonicalPath }}" />{{ end }}
{{ range .Data.Alternates }}<link rel="alternate" hreflang="{{ .Locale }}" href="{{ RootURL }}{{ .Path }}" />
{{ end }}{{ if .Data.XDefaultPath }}<link rel="alternate" hreflang="x-default" href="{{ RootURL }}{{ .Data.XDefaultPath }}" />{{ end }}
{{ if .Data.CanonicalPath }}<link rel="canonical" href="{{ .Data.HelpCenter.BaseURL }}{{ .Data.CanonicalPath }}" />{{ end }}
{{ range .Data.Alternates }}<link rel="alternate" hreflang="{{ .Locale }}" href="{{ $.Data.HelpCenter.BaseURL }}{{ .Path }}" />
{{ end }}{{ if .Data.XDefaultPath }}<link rel="alternate" hreflang="x-default" href="{{ .Data.HelpCenter.BaseURL }}{{ .Data.XDefaultPath }}" />{{ end }}
<meta property="og:title" content="{{ .Data.Title }}" />
{{ if .Data.MetaDescription }}<meta property="og:description" content="{{ .Data.MetaDescription }}" />{{ end }}
<meta property="og:type" content="{{ if .Data.OGType }}{{ .Data.OGType }}{{ else }}website{{ end }}" />
<meta property="og:site_name" content="{{ .Data.HelpCenter.Name }}" />
<meta property="og:locale" content="{{ .Data.HelpCenter.OGLocale }}" />
{{ if .Data.CanonicalPath }}<meta property="og:url" content="{{ RootURL }}{{ .Data.CanonicalPath }}" />{{ end }}
{{ if .Data.CanonicalPath }}<meta property="og:url" content="{{ .Data.HelpCenter.BaseURL }}{{ .Data.CanonicalPath }}" />{{ end }}
{{ if .Data.OGImage }}<meta property="og:image" content="{{ .Data.OGImage }}" />{{ end }}
{{ if .Data.PublishedTime }}<meta property="article:published_time" content="{{ .Data.PublishedTime }}" />{{ end }}
{{ if .Data.ModifiedTime }}<meta property="article:modified_time" content="{{ .Data.ModifiedTime }}" />{{ end }}