312 lines
9.3 KiB
TypeScript
312 lines
9.3 KiB
TypeScript
import 'server-only'
|
|
|
|
import { isFeatureEnabled } from '@lib/featureToggles'
|
|
import docsManifest from '../../public/dl-index/docs-manifest.json'
|
|
import fallbackDocsIndex from '../../public/_build/docs_index.json'
|
|
|
|
import { buildAbsoluteDocUrl } from './utils'
|
|
import type { DocCollection, DocResource, DocVersionOption } from './types'
|
|
|
|
interface RawDocResource {
|
|
slug?: unknown
|
|
title?: unknown
|
|
description?: unknown
|
|
category?: unknown
|
|
version?: unknown
|
|
updatedAt?: unknown
|
|
pdfUrl?: unknown
|
|
htmlUrl?: unknown
|
|
tags?: unknown
|
|
estimatedMinutes?: unknown
|
|
coverImage?: unknown
|
|
language?: unknown
|
|
variant?: unknown
|
|
versionSlug?: unknown
|
|
pathSegments?: unknown
|
|
collection?: unknown
|
|
collectionSlug?: unknown
|
|
collectionLabel?: unknown
|
|
}
|
|
|
|
const manifestDocs = Array.isArray(docsManifest) ? (docsManifest as RawDocResource[]) : []
|
|
const fallbackDocs = Array.isArray(fallbackDocsIndex) ? (fallbackDocsIndex as RawDocResource[]) : []
|
|
|
|
const RAW_DOCS = manifestDocs.length > 0 ? manifestDocs : fallbackDocs
|
|
|
|
export const DOCS_DATASET = RAW_DOCS.map((item) => normalizeResource(item as RawDocResource)).filter(
|
|
(item): item is DocResource => item !== null,
|
|
)
|
|
|
|
|
|
function slugifySegment(value: string): string {
|
|
const base = value
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
return base || value.toLowerCase().replace(/\s+/g, '-') || 'doc'
|
|
}
|
|
|
|
function humanizeSegment(value: string): string {
|
|
if (!value) return ''
|
|
const withSpaces = value
|
|
.replace(/[_-]+/g, ' ')
|
|
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
const normalized = withSpaces.replace(/\s+/g, ' ').trim()
|
|
if (!normalized) return value
|
|
return normalized
|
|
.split(' ')
|
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
.join(' ')
|
|
}
|
|
|
|
function parseUpdatedAt(value?: string): number {
|
|
if (!value) return 0
|
|
const ts = Date.parse(value)
|
|
return Number.isNaN(ts) ? 0 : ts
|
|
}
|
|
|
|
interface CollectionAccumulator {
|
|
directory: string
|
|
slug: string
|
|
label: string
|
|
docs: DocResource[]
|
|
}
|
|
|
|
function buildCollections(docs: DocResource[]): DocCollection[] {
|
|
const map = new Map<string, CollectionAccumulator>()
|
|
const usedSlugs = new Set<string>()
|
|
|
|
const ensureUniqueSlug = (slug: string) => {
|
|
let candidate = slug || 'doc'
|
|
if (!usedSlugs.has(candidate)) {
|
|
usedSlugs.add(candidate)
|
|
return candidate
|
|
}
|
|
let counter = 2
|
|
while (usedSlugs.has(`${candidate}-${counter}`)) {
|
|
counter += 1
|
|
}
|
|
const unique = `${candidate}-${counter}`
|
|
usedSlugs.add(unique)
|
|
return unique
|
|
}
|
|
|
|
for (const doc of docs) {
|
|
const directory = doc.collection ?? doc.pathSegments?.[0] ?? doc.category ?? doc.slug
|
|
if (!directory) {
|
|
continue
|
|
}
|
|
if (directory === 'all.json' || directory === 'dir.json') {
|
|
continue
|
|
}
|
|
|
|
const slug = doc.collectionSlug ?? slugifySegment(directory)
|
|
const label = doc.collectionLabel ?? doc.category ?? humanizeSegment(directory)
|
|
const key = directory
|
|
const existing = map.get(key)
|
|
if (existing) {
|
|
existing.docs.push(doc)
|
|
continue
|
|
}
|
|
|
|
map.set(key, {
|
|
directory,
|
|
slug: ensureUniqueSlug(slug),
|
|
label,
|
|
docs: [doc],
|
|
})
|
|
}
|
|
|
|
const collections: DocCollection[] = []
|
|
for (const accumulator of map.values()) {
|
|
const docsSorted = [...accumulator.docs].sort((a, b) => parseUpdatedAt(b.updatedAt) - parseUpdatedAt(a.updatedAt))
|
|
const primary = docsSorted[0]
|
|
if (!primary) {
|
|
continue
|
|
}
|
|
|
|
const tagSet = new Set<string>()
|
|
for (const doc of docsSorted) {
|
|
if (!doc.tags) continue
|
|
for (const tag of doc.tags) {
|
|
if (tag) tagSet.add(tag)
|
|
}
|
|
}
|
|
|
|
const versionSlugSet = new Set<string>()
|
|
const ensureUniqueVersionSlug = (slug: string) => {
|
|
let candidate = slug || 'version'
|
|
if (!versionSlugSet.has(candidate)) {
|
|
versionSlugSet.add(candidate)
|
|
return candidate
|
|
}
|
|
let counter = 2
|
|
while (versionSlugSet.has(`${candidate}-${counter}`)) {
|
|
counter += 1
|
|
}
|
|
const unique = `${candidate}-${counter}`
|
|
versionSlugSet.add(unique)
|
|
return unique
|
|
}
|
|
|
|
const versions: DocVersionOption[] = docsSorted.map((doc) => {
|
|
const labelParts: string[] = []
|
|
if (doc.version) {
|
|
labelParts.push(doc.version)
|
|
}
|
|
if (!doc.version && doc.variant) {
|
|
labelParts.push(doc.variant)
|
|
}
|
|
if (doc.language && !labelParts.includes(doc.language)) {
|
|
labelParts.push(doc.language)
|
|
}
|
|
const label = labelParts.length > 0 ? labelParts.join(' • ') : doc.title
|
|
let versionSlug = doc.versionSlug
|
|
if (!versionSlug || !versionSlug.trim()) {
|
|
const candidate = doc.variant ?? doc.version ?? doc.slug
|
|
versionSlug = slugifySegment(candidate)
|
|
}
|
|
versionSlug = ensureUniqueVersionSlug(versionSlug)
|
|
return {
|
|
id: doc.slug,
|
|
label,
|
|
resource: doc,
|
|
slug: versionSlug,
|
|
pathSegment: doc.pathSegments?.[1],
|
|
}
|
|
})
|
|
|
|
const collection: DocCollection = {
|
|
slug: accumulator.slug,
|
|
title: primary.title,
|
|
description: primary.description,
|
|
category: primary.category ?? accumulator.label,
|
|
updatedAt: primary.updatedAt,
|
|
estimatedMinutes: primary.estimatedMinutes,
|
|
tags: Array.from(tagSet).sort((a, b) => a.localeCompare(b)),
|
|
latestVersionLabel: versions[0]?.label,
|
|
latestVariant: primary.variant,
|
|
versions,
|
|
defaultVersionId: versions[0]?.id,
|
|
defaultVersionSlug: versions[0]?.slug,
|
|
directory: accumulator.directory,
|
|
}
|
|
|
|
collections.push(collection)
|
|
}
|
|
|
|
return collections.sort((a, b) => parseUpdatedAt(b.updatedAt) - parseUpdatedAt(a.updatedAt))
|
|
}
|
|
|
|
export const DOC_COLLECTIONS = buildCollections(DOCS_DATASET)
|
|
|
|
function normalizeResource(item: RawDocResource): DocResource | null {
|
|
if (!item || typeof item !== 'object') {
|
|
return null
|
|
}
|
|
|
|
const slug = typeof item.slug === 'string' ? item.slug : undefined
|
|
const title = typeof item.title === 'string' ? item.title : undefined
|
|
if (!slug || !title) {
|
|
return null
|
|
}
|
|
|
|
const resource: DocResource = {
|
|
slug,
|
|
title,
|
|
description: typeof item.description === 'string' ? item.description : '',
|
|
}
|
|
|
|
if (typeof item.category === 'string' && item.category.trim()) {
|
|
resource.category = item.category
|
|
}
|
|
if (typeof item.version === 'string' && item.version.trim()) {
|
|
resource.version = item.version
|
|
}
|
|
if (typeof item.updatedAt === 'string' && item.updatedAt.trim()) {
|
|
resource.updatedAt = item.updatedAt
|
|
}
|
|
if (typeof item.pdfUrl === 'string' && item.pdfUrl.trim()) {
|
|
resource.pdfUrl = buildAbsoluteDocUrl(item.pdfUrl) ?? item.pdfUrl
|
|
}
|
|
if (typeof item.htmlUrl === 'string' && item.htmlUrl.trim()) {
|
|
resource.htmlUrl = buildAbsoluteDocUrl(item.htmlUrl) ?? item.htmlUrl
|
|
}
|
|
if (typeof item.language === 'string' && item.language.trim()) {
|
|
resource.language = item.language
|
|
}
|
|
if (typeof item.variant === 'string' && item.variant.trim()) {
|
|
resource.variant = item.variant
|
|
}
|
|
if (typeof item.versionSlug === 'string' && item.versionSlug.trim()) {
|
|
resource.versionSlug = item.versionSlug
|
|
}
|
|
if (typeof item.collection === 'string' && item.collection.trim()) {
|
|
resource.collection = item.collection
|
|
}
|
|
if (typeof item.collectionSlug === 'string' && item.collectionSlug.trim()) {
|
|
resource.collectionSlug = item.collectionSlug
|
|
}
|
|
if (typeof item.collectionLabel === 'string' && item.collectionLabel.trim()) {
|
|
resource.collectionLabel = item.collectionLabel
|
|
}
|
|
if (typeof item.estimatedMinutes === 'number' && !Number.isNaN(item.estimatedMinutes)) {
|
|
resource.estimatedMinutes = item.estimatedMinutes
|
|
}
|
|
if (typeof item.coverImage === 'string' && item.coverImage.trim()) {
|
|
resource.coverImage = item.coverImage
|
|
}
|
|
if (Array.isArray(item.tags)) {
|
|
const tags = item.tags.filter((tag): tag is string => typeof tag === 'string' && tag.trim().length > 0)
|
|
if (tags.length > 0) {
|
|
resource.tags = [...new Set(tags)]
|
|
}
|
|
}
|
|
if (Array.isArray(item.pathSegments)) {
|
|
const segments = item.pathSegments.filter((segment): segment is string => typeof segment === 'string' && segment.trim().length > 0)
|
|
if (segments.length > 0) {
|
|
resource.pathSegments = segments
|
|
}
|
|
}
|
|
|
|
if (!resource.description.trim()) {
|
|
const context: string[] = []
|
|
if (resource.category) {
|
|
context.push(resource.category)
|
|
}
|
|
if (resource.version) {
|
|
context.push(`edition ${resource.version}`)
|
|
} else if (resource.variant) {
|
|
context.push(`release ${resource.variant}`)
|
|
}
|
|
const formats: string[] = []
|
|
if (resource.pdfUrl) formats.push('PDF')
|
|
if (resource.htmlUrl) formats.push('HTML')
|
|
if (formats.length > 0) {
|
|
context.push(`available as ${formats.join(' and ')}`)
|
|
}
|
|
const suffix = context.length > 0 ? ` (${context.join(', ')})` : ''
|
|
resource.description = `${resource.title}${suffix}.`
|
|
}
|
|
|
|
return resource
|
|
}
|
|
|
|
const isDocsModuleEnabled = () => isFeatureEnabled('appModules', '/docs')
|
|
|
|
export async function getDocResources(): Promise<DocCollection[]> {
|
|
if (!isDocsModuleEnabled()) {
|
|
return []
|
|
}
|
|
|
|
return DOC_COLLECTIONS
|
|
}
|
|
|
|
export async function getDocResource(slug: string): Promise<DocCollection | undefined> {
|
|
if (!isDocsModuleEnabled()) {
|
|
return undefined
|
|
}
|
|
|
|
return DOC_COLLECTIONS.find((doc) => doc.slug === slug)
|
|
}
|