@fullstackdatasolutions/articles 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +211 -11
- package/dist/nextjs-middleware.cjs +61 -0
- package/dist/nextjs-middleware.cjs.map +1 -0
- package/dist/nextjs-middleware.d.cts +25 -0
- package/dist/nextjs-middleware.d.ts +25 -0
- package/dist/nextjs-middleware.js +33 -0
- package/dist/nextjs-middleware.js.map +1 -0
- package/dist/nextjs.cjs +652 -0
- package/dist/nextjs.cjs.map +1 -0
- package/dist/nextjs.d.cts +112 -0
- package/dist/nextjs.d.ts +112 -0
- package/dist/nextjs.js +618 -0
- package/dist/nextjs.js.map +1 -0
- package/package.json +26 -3
- package/src/__tests__/nextjs-middleware.test.ts +183 -0
- package/src/__tests__/nextjs.test.ts +137 -0
- package/src/nextjs-middleware.ts +48 -0
- package/src/nextjs.ts +27 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/server-articles.ts","../src/markdown.ts","../src/errorReporting.ts","../src/nextjs.ts"],"sourcesContent":["import { cache } from 'react'\nimport matter from 'gray-matter'\nimport fs from 'node:fs'\nimport path from 'node:path'\nimport readingTime from 'reading-time'\nimport { markdownToHtml, extractToc } from './markdown'\nimport type { Article, CategoryInfo, FaqItem, HowToStep } from './articleTypes'\nimport type { ArticlesConfig } from './articlesConfig'\nimport { reportArticlesError } from './errorReporting'\n\nconst articlesDirectory = path.join(/* turbopackIgnore: true */ process.cwd(), 'public/articles')\n\nfunction getReadingTime(content: string): string {\n return readingTime(content).text\n}\n\nfunction findArticleImage(slug: string): string | null {\n try {\n const articleDir = path.join(articlesDirectory, slug)\n const entries: (fs.Dirent | string)[] = fs.readdirSync(articleDir, { withFileTypes: true })\n const names = entries\n .map((entry) => {\n if (typeof entry === 'string') return entry\n if (entry && typeof entry.name === 'string') return entry.name\n return null\n })\n .filter((name): name is string => Boolean(name))\n const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp']\n const imageFileName = names.find((name) =>\n imageExtensions.some((ext) => name.toLowerCase().endsWith(ext))\n )\n return imageFileName ? sanitizeImagePath(imageFileName, slug) : null\n } catch {\n return null\n }\n}\n\nfunction sanitizeImagePath(rawPath: string, articleSlug: string): string | null {\n if (!rawPath || typeof rawPath !== 'string') return null\n const cleanPath = rawPath.replaceAll(/[\\x00-\\x1f\\x7f-\\x9f]/g, '')\n if (cleanPath.startsWith('http://') || cleanPath.startsWith('https://')) return cleanPath\n if (cleanPath.includes('..') || cleanPath.includes('\\\\') || cleanPath.startsWith('/')) {\n reportArticlesError({\n code: 'unsafe-image-path',\n message: 'Rejected unsafe article image path.',\n context: { articleSlug, path: cleanPath },\n })\n return null\n }\n if (!/^[a-zA-Z0-9._/-]+$/.test(cleanPath)) {\n reportArticlesError({\n code: 'unsafe-image-path',\n message: 'Rejected article image path with invalid characters.',\n context: { articleSlug, path: cleanPath },\n })\n return null\n }\n if (cleanPath.includes('/')) {\n const normalizedPath = path.normalize(cleanPath)\n if (normalizedPath.startsWith('..') || normalizedPath.includes('../')) {\n reportArticlesError({\n code: 'unsafe-image-path',\n message: 'Rejected article image path traversal attempt.',\n context: { articleSlug, path: cleanPath },\n })\n return null\n }\n return `/articles/${articleSlug}/${cleanPath}`\n }\n return `/articles/${articleSlug}/${cleanPath}`\n}\n\nfunction findArticleFile(slug: string): { filePath: string; contentType: 'md' | 'mdx' } | null {\n const mdPath = path.join(articlesDirectory, slug, 'article.md')\n const mdxPath = path.join(articlesDirectory, slug, 'article.mdx')\n if (fs.existsSync(mdPath)) return { filePath: mdPath, contentType: 'md' }\n if (fs.existsSync(mdxPath)) return { filePath: mdxPath, contentType: 'mdx' }\n return null\n}\n\nexport function getAvailableArticleSlugs(): string[] {\n try {\n if (!fs.existsSync(articlesDirectory)) return []\n return walkArticleDir(articlesDirectory, '')\n } catch (error) {\n reportArticlesError({\n code: 'article-directory-read-failed',\n message: 'Unable to read articles directory.',\n error,\n context: { directory: articlesDirectory },\n })\n return []\n }\n}\n\nfunction walkArticleDir(dir: string, baseSlug: string): string[] {\n const slugs: string[] = []\n try {\n const items = fs.readdirSync(dir, { withFileTypes: true })\n for (const item of items) {\n if (!item.isDirectory()) continue\n const slug = baseSlug ? `${baseSlug}/${item.name}` : item.name\n if (findArticleFile(slug) !== null) {\n slugs.push(slug)\n }\n slugs.push(...walkArticleDir(path.join(dir, item.name), slug))\n }\n } catch {\n // ignore unreadable directories\n }\n return slugs\n}\n\nfunction parseDateField(rawDate: unknown): string | undefined {\n if (!rawDate) return undefined\n try {\n const parsed = new Date(rawDate as string)\n if (!Number.isNaN(parsed.getTime())) return parsed.toISOString().split('T')[0]\n } catch {\n // ignore invalid dates\n }\n return undefined\n}\n\nfunction resolveFeaturedImage(rawImage: unknown, slug: string): string {\n const img = typeof rawImage === 'string' ? rawImage : ''\n if (img && !img.startsWith('http')) return sanitizeImagePath(img, slug) || '/placeholder-logo.png'\n if (!img) return findArticleImage(slug) || '/placeholder-logo.png'\n return img\n}\n\nfunction parseFaqItems(raw: unknown): FaqItem[] | undefined {\n if (!Array.isArray(raw)) return undefined\n const items = raw.filter(\n (item): item is FaqItem =>\n typeof item === 'object' &&\n item !== null &&\n typeof (item as FaqItem).question === 'string' &&\n typeof (item as FaqItem).answer === 'string'\n )\n return items.length ? items : undefined\n}\n\nfunction parseHowToSteps(raw: unknown): HowToStep[] | undefined {\n if (!Array.isArray(raw)) return undefined\n const steps = raw.filter(\n (item): item is HowToStep =>\n typeof item === 'object' &&\n item !== null &&\n typeof (item as HowToStep).name === 'string' &&\n typeof (item as HowToStep).text === 'string'\n )\n return steps.length ? steps : undefined\n}\n\nasync function getArticleSummary(slug: string): Promise<Article | null> {\n try {\n const found = findArticleFile(slug)\n if (!found) return null\n const fileContent = fs.readFileSync(found.filePath, 'utf8')\n const { data, content: markdownContent } = matter(fileContent)\n const readTime = getReadingTime(markdownContent)\n const allTags: string[] = Array.isArray(data.tags)\n ? data.tags.filter((t: unknown) => typeof t === 'string' && String(t).trim())\n : []\n const categories: string[] =\n allTags.length > 0 ? allTags.map((t: string) => t.replaceAll('-', ' ').trim()) : ['Campaigns']\n return {\n slug,\n title: data.title || slug.replaceAll('-', ' '),\n excerpt: data.excerpt || '',\n date: parseDateField(data.date),\n lastmod: parseDateField(data.lastmod),\n author: data.author || 'Andrew Blase',\n category: categories[0],\n categories,\n readTime,\n featuredImage: resolveFeaturedImage(data.featuredImage, slug),\n tags: data.tags || [],\n contentType: found.contentType,\n draft: data.draft === true,\n faq: parseFaqItems(data.faq),\n howTo: parseHowToSteps(data.howTo),\n canonicalUrl: typeof data.canonicalUrl === 'string' ? data.canonicalUrl : undefined,\n articleType: typeof data.articleType === 'string' ? data.articleType : undefined,\n series: typeof data.series === 'string' ? data.series : undefined,\n aiCrawl: data.aiCrawl === true,\n }\n } catch (error) {\n reportArticlesError({\n code: 'article-load-failed',\n message: 'Unable to load article summary.',\n error,\n context: { slug },\n })\n return null\n }\n}\n\nexport const getArticleMetadata = cache(async (slug: string): Promise<Article | null> => {\n try {\n const summary = await getArticleSummary(slug)\n if (!summary) return null\n const found = findArticleFile(slug)\n if (!found) return null\n const fileContent = fs.readFileSync(found.filePath, 'utf8')\n const { content: markdownContent } = matter(fileContent)\n const toc = await extractToc(markdownContent)\n let htmlContent: string | undefined\n let mdxSource: string | undefined\n if (found.contentType === 'mdx') {\n mdxSource = markdownContent\n } else {\n htmlContent = await markdownToHtml(markdownContent, slug)\n }\n return { ...summary, content: markdownContent, htmlContent, mdxSource, toc }\n } catch (error) {\n reportArticlesError({\n code: 'article-load-failed',\n message: 'Unable to load article metadata.',\n error,\n context: { slug },\n })\n return null\n }\n})\n\nexport const getAllArticles = cache(async (): Promise<Article[]> => {\n const slugs = getAvailableArticleSlugs()\n const articles = await Promise.all(slugs.map((slug) => getArticleSummary(slug)))\n const currentDate = new Date().toISOString().split('T')[0]\n return articles\n .filter((article): article is Article => article !== null)\n .filter((article) => !article.date || article.date <= currentDate)\n .filter((article) => !(article.draft && process.env.NODE_ENV === 'production'))\n .sort((a, b) => {\n if (!a.date && !b.date) return 0\n if (!a.date) return 1\n if (!b.date) return -1\n return new Date(b.date).getTime() - new Date(a.date).getTime()\n })\n})\n\nexport async function getAdjacentArticles(\n currentSlug: string\n): Promise<{ previous: Article | null; next: Article | null }> {\n const allArticles = await getAllArticles()\n const currentIndex = allArticles.findIndex((article) => article.slug === currentSlug)\n if (currentIndex === -1) return { previous: null, next: null }\n const previous = currentIndex < allArticles.length - 1 ? allArticles[currentIndex + 1] : null\n const next = currentIndex > 0 ? allArticles[currentIndex - 1] : null\n return { previous, next }\n}\n\nexport async function getArticleMarkdown(slug: string): Promise<string | null> {\n try {\n const summary = await getArticleSummary(slug)\n if (!summary?.aiCrawl) return null\n const found = findArticleFile(slug)\n if (!found) return null\n const fileContent = fs.readFileSync(found.filePath, 'utf8')\n const { content: markdownContent } = matter(fileContent)\n return markdownContent\n } catch (error) {\n reportArticlesError({\n code: 'article-markdown-load-failed',\n message: 'Unable to load article markdown.',\n error,\n context: { slug },\n })\n return null\n }\n}\n\nexport async function getArticleMarkdownResponse(\n slug: string,\n config: ArticlesConfig\n): Promise<Response> {\n const markdown = await getArticleMarkdown(slug)\n if (markdown === null) return new Response('Not Found', { status: 404 })\n const article = await getArticleMetadata(slug)\n return new Response(markdown, {\n headers: {\n 'Content-Type': 'text/markdown; charset=utf-8',\n 'Cache-Control': 'public, max-age=3600, s-maxage=3600',\n ...(article ? getArticleAiHeaders(article, config) : {}),\n },\n })\n}\n\nexport function getArticleMarkdownUrl(\n article: Pick<Article, 'slug' | 'aiCrawl'>,\n config?: Pick<ArticlesConfig, 'siteUrl'>\n): string | undefined {\n if (article.aiCrawl !== true) return undefined\n const pathname = `/articles/${article.slug}.md`\n if (!config) return pathname\n return `${config.siteUrl.replace(/\\/$/, '')}${pathname}`\n}\n\nexport function getArticleAiHeaders(\n article: Pick<Article, 'slug' | 'aiCrawl'>,\n config?: Pick<ArticlesConfig, 'siteUrl'>\n): Record<string, string> {\n const markdownUrl = getArticleMarkdownUrl(article, config)\n if (markdownUrl) {\n return {\n Link: `<${markdownUrl}>; rel=\"alternate\"; type=\"text/markdown\"`,\n }\n }\n return {\n 'X-Robots-Tag': 'noai, noimageai',\n }\n}\n\nexport async function getAiRobotsTxtRules(): Promise<string> {\n const articles = await getAllArticles()\n const blockedArticles = articles.filter((article) => article.aiCrawl !== true)\n if (blockedArticles.length === 0) return ''\n\n const aiCrawlers = [\n 'GPTBot',\n 'ChatGPT-User',\n 'CCBot',\n 'ClaudeBot',\n 'Claude-User',\n 'PerplexityBot',\n 'Google-Extended',\n ]\n const disallowRules = blockedArticles\n .map((article) => `Disallow: /articles/${article.slug}`)\n .join('\\n')\n\n return aiCrawlers\n .map((crawler) => [`User-agent: ${crawler}`, disallowRules].join('\\n'))\n .join('\\n\\n')\n}\n\nexport async function searchArticles(query: string, config?: ArticlesConfig): Promise<Article[]> {\n if (!query?.trim()) return getAllArticles()\n const articles = await getAllArticles()\n const searchTerm = query.toLowerCase().trim()\n const includeAuthor = config?.showAuthor !== false\n return articles.filter((article) => {\n const matchesTitle = article.title.toLowerCase().includes(searchTerm)\n const matchesExcerpt = article.excerpt.toLowerCase().includes(searchTerm)\n const matchesAuthor = includeAuthor && article.author.toLowerCase().includes(searchTerm)\n const matchesCategory = article.categories.some((cat) => cat.toLowerCase().includes(searchTerm))\n const matchesTags = article.tags?.some((tag) => tag.toLowerCase().includes(searchTerm))\n return (\n matchesTitle || matchesExcerpt || matchesAuthor || matchesCategory || Boolean(matchesTags)\n )\n })\n}\n\nexport function categoryToSlug(category: string): string {\n return category\n .toLowerCase()\n .replaceAll(/\\s+/g, '-')\n .replaceAll(/[^a-z0-9-]/g, '')\n}\n\nexport async function getAllCategories(): Promise<CategoryInfo[]> {\n const articles = await getAllArticles()\n const categoryMap = new Map<string, { count: number; featuredImage: string }>()\n for (const article of articles) {\n for (const cat of article.categories) {\n if (!categoryMap.has(cat)) {\n categoryMap.set(cat, { count: 0, featuredImage: article.featuredImage })\n }\n categoryMap.get(cat)!.count++\n }\n }\n return Array.from(categoryMap.entries())\n .map(([name, { count, featuredImage }]) => ({\n name,\n slug: categoryToSlug(name),\n count,\n featuredImage,\n }))\n .sort((a, b) => b.count - a.count)\n}\n\nexport async function getArticlesByCategory(categorySlug: string): Promise<Article[]> {\n const articles = await getAllArticles()\n return articles.filter((article) =>\n article.categories.some((cat) => categoryToSlug(cat) === categorySlug)\n )\n}\n\nexport { sanitizeImagePath }\n","import type { Element, Root, ElementContent } from 'hast'\nimport rehypePrism from 'rehype-prism-plus'\nimport rehypeSanitize from 'rehype-sanitize'\nimport rehypeSlug from 'rehype-slug'\nimport rehypeStringify from 'rehype-stringify'\nimport { remark } from 'remark'\nimport remarkGfm from 'remark-gfm'\nimport remarkGithubBlockquoteAlert from 'remark-github-blockquote-alert'\nimport remarkParse from 'remark-parse'\nimport remarkRehype from 'remark-rehype'\nimport { Plugin } from 'unified'\nimport { visit } from 'unist-util-visit'\nimport type { TocItem } from './articleTypes'\nimport { reportArticlesError } from './errorReporting'\n\n// Import the sanitizeImagePath function\nfunction sanitizeImagePath(rawPath: string, articleSlug: string): string | null {\n if (!rawPath || typeof rawPath !== 'string') {\n return null\n }\n\n // Remove any null bytes or control characters\n const cleanPath = rawPath.replaceAll(/[\\x00-\\x1f\\x7f-\\x9f]/g, '')\n\n // Check for absolute URLs (http/https)\n if (cleanPath.startsWith('http://') || cleanPath.startsWith('https://')) {\n // For external URLs, just return as-is (they're safe)\n return cleanPath\n }\n\n // For relative paths, ensure they don't contain dangerous patterns\n if (cleanPath.includes('..') || cleanPath.includes('\\\\') || cleanPath.startsWith('/')) {\n reportArticlesError({\n code: 'unsafe-image-path',\n message: 'Rejected unsafe markdown image path.',\n context: { articleSlug, path: cleanPath },\n })\n return null\n }\n\n // Only allow alphanumeric characters, hyphens, underscores, dots, and forward slashes\n if (!/^[a-zA-Z0-9._/-]+$/.test(cleanPath)) {\n reportArticlesError({\n code: 'unsafe-image-path',\n message: 'Rejected markdown image path with invalid characters.',\n context: { articleSlug, path: cleanPath },\n })\n return null\n }\n\n // Construct safe path within articles directory\n if (cleanPath.includes('/')) {\n // Relative path, ensure it's within the article directory\n const normalizedPath = cleanPath.replaceAll('\\\\', '/')\n if (normalizedPath.startsWith('..') || normalizedPath.includes('../')) {\n reportArticlesError({\n code: 'unsafe-image-path',\n message: 'Rejected markdown image path traversal attempt.',\n context: { articleSlug, path: cleanPath },\n })\n return null\n }\n return `/articles/${articleSlug}/${normalizedPath}`\n } else {\n // Just a filename, construct full path\n return `/articles/${articleSlug}/${cleanPath}`\n }\n}\n\nfunction styleFootnoteLinks(nodes: ElementContent[]): void {\n nodes.forEach((n) => {\n if (n.type !== 'element') return\n const el = n as Element\n if (el.tagName === 'a') {\n const isBackRef =\n el.properties?.['dataFootnoteBackref'] !== undefined ||\n (el.children[0]?.type === 'text' && el.children[0].value === '↩')\n if (isBackRef) {\n el.properties.className = 'text-primary hover:underline ml-1'\n if (typeof el.properties.href === 'string') {\n el.properties.href = el.properties.href.replaceAll('#user-content-fnref-', '#ref-')\n }\n } else {\n el.properties.className = 'text-primary hover:underline break-all'\n }\n }\n if (el.children) styleFootnoteLinks(el.children)\n })\n}\n\nfunction processFootnoteRef(node: Element): void {\n if (\n node.tagName !== 'sup' ||\n node.children?.[0]?.type !== 'element' ||\n (node.children[0] as Element).tagName !== 'a'\n )\n return\n\n const link = node.children[0] as Element\n const href = link.properties?.href\n if (typeof href !== 'string' || !href.startsWith('#user-content-fn-')) return\n\n delete link.properties.target\n delete link.properties.rel\n link.properties.className = 'text-primary hover:underline'\n link.properties.href = href.replaceAll('#user-content-fn-', '#footnote-')\n\n if (typeof node.properties?.id === 'string') {\n link.properties.id = node.properties.id.replaceAll('user-content-fnref-', 'ref-')\n delete node.properties.id\n }\n\n if (link.children?.[0]?.type === 'text') {\n link.children[0].value = `[${link.children[0].value}]`\n }\n}\n\nfunction processFootnotesSection(node: Element): void {\n const cls = node.properties?.className\n const isFootnotes =\n node.tagName === 'section' &&\n (Array.isArray(cls) ? cls.includes('footnotes') : cls === 'footnotes')\n if (!isFootnotes) return\n\n const olCandidate = node.children.find(\n (child) => child.type === 'element' && (child as Element).tagName === 'ol'\n )\n if (olCandidate?.type !== 'element') return\n\n const ol = olCandidate as Element\n ol.properties.className = 'list-decimal ml-6 space-y-2 text-sm text-muted-foreground'\n\n ol.children.forEach((li) => {\n if (li.type !== 'element' || li.tagName !== 'li') return\n const liEl = li as Element\n\n if (liEl.properties) {\n liEl.properties.className = 'pl-2'\n if (typeof liEl.properties.id === 'string') {\n liEl.properties.id = liEl.properties.id.replaceAll('user-content-fn-', 'footnote-')\n }\n }\n\n const pIndex = liEl.children.findIndex(\n (child) => child.type === 'element' && (child as Element).tagName === 'p'\n )\n if (pIndex !== -1) {\n const p = liEl.children[pIndex] as Element\n liEl.children.splice(pIndex, 1, ...p.children)\n }\n\n styleFootnoteLinks(liEl.children)\n })\n\n const hr: Element = {\n type: 'element',\n tagName: 'hr',\n properties: { className: 'my-8 border-border' },\n children: [],\n }\n const h3: Element = {\n type: 'element',\n tagName: 'h3',\n properties: { className: 'text-lg font-semibold mb-4' },\n children: [{ type: 'text', value: 'References' }],\n }\n\n node.children = [hr, h3, ol]\n if (node.properties) node.properties.className = undefined\n}\n\nexport const customRenderer: Plugin<[], Root> = () => {\n return (tree: Root) => {\n // First pass: Apply general styles\n visit(tree, 'element', (node: Element) => {\n if (node.tagName) {\n const props = node.properties || {}\n\n switch (node.tagName) {\n case 'h1':\n props.className = 'text-3xl font-bold text-foreground mt-8 mb-4 scroll-mt-20'\n break\n case 'h2':\n props.className = 'text-2xl font-semibold text-foreground mt-6 mb-3 scroll-mt-20'\n break\n case 'h3':\n props.className = 'text-xl font-semibold text-foreground mt-4 mb-2 scroll-mt-20'\n break\n case 'h4':\n props.className = 'text-lg font-semibold text-foreground mt-3 mb-2 scroll-mt-20'\n break\n case 'p':\n props.className = 'text-muted-foreground leading-relaxed mb-4'\n break\n case 'a': {\n props.className = 'text-primary hover:underline transition-colors duration-200'\n const href = typeof props.href === 'string' ? props.href : ''\n if (!href.startsWith('#')) {\n props.target = '_blank'\n props.rel = 'noopener noreferrer'\n }\n break\n }\n case 'ul':\n props.className = 'list-disc list-inside mb-4 space-y-2 ml-4'\n break\n case 'ol':\n props.className = 'list-decimal list-inside mb-4 space-y-2 ml-4'\n break\n case 'li':\n props.className = 'mb-1'\n break\n case 'blockquote':\n props.className = 'border-l-4 border-primary pl-4 italic my-4 text-muted-foreground'\n break\n case 'code':\n props.className =\n (props.className ? props.className + ' ' : '') +\n 'bg-muted px-1 py-0.5 rounded text-sm font-mono'\n break\n case 'pre':\n props.className = 'bg-muted rounded-lg p-4 overflow-x-auto my-4'\n break\n case 'img':\n props.className = 'rounded-lg my-6 w-full max-w-2xl mx-auto'\n break\n case 'table':\n props.className = 'border-collapse border border-border my-4 w-full'\n break\n case 'th':\n props.className = 'border border-border px-2 py-1 bg-muted font-semibold'\n break\n case 'td':\n props.className = 'border border-border px-2 py-1'\n break\n case 'hr':\n props.className = 'my-8 border-border'\n break\n default:\n break\n }\n\n node.properties = props\n }\n })\n\n // Second pass: Fix footnotes and references\n visit(tree, 'element', (node: Element) => {\n processFootnoteRef(node)\n processFootnotesSection(node)\n })\n }\n}\n\n// Custom rehype plugin to process image URLs\nconst rehypeProcessImages: Plugin<[{ articleSlug?: string }], Root> = (options = {}) => {\n return (tree: Root) => {\n visit(tree, 'element', (node: Element) => {\n if (node.tagName === 'img' && node.properties) {\n const src = node.properties.src\n if (src && typeof src === 'string' && options.articleSlug) {\n // Sanitize the image path\n const sanitizedSrc = sanitizeImagePath(src, options.articleSlug)\n if (sanitizedSrc) {\n node.properties.src = sanitizedSrc\n } else {\n // If sanitization fails, use a placeholder\n reportArticlesError({\n code: 'unsafe-image-path',\n message: 'Using placeholder for unsafe markdown image path.',\n context: { articleSlug: options.articleSlug, path: src },\n })\n node.properties.src = '/placeholder-logo.png'\n }\n }\n }\n })\n }\n}\n\nexport async function markdownToHtml(markdown: string, articleSlug?: string) {\n try {\n // Start building the remark processor\n let processor = remark()\n .use(remarkParse)\n .use(remarkGfm)\n .use(remarkGithubBlockquoteAlert)\n .use(remarkRehype)\n .use(customRenderer)\n .use(rehypeSlug)\n // @ts-ignore\n .use(rehypePrism)\n .use(rehypeSanitize, {\n attributes: {\n '*': ['className', 'class', 'id'],\n a: ['href', 'target', 'rel', 'id'],\n img: ['src', 'alt'],\n },\n })\n\n // Add image processing plugin if articleSlug is provided\n if (articleSlug) {\n processor = processor.use(rehypeProcessImages, { articleSlug })\n }\n\n const result = await processor.use(rehypeStringify).process(markdown)\n\n return result.toString()\n } catch (error) {\n reportArticlesError({\n code: 'markdown-conversion-failed',\n message: 'Unable to convert markdown to HTML.',\n error,\n context: { articleSlug },\n })\n // Return the original markdown as fallback\n return markdown\n }\n}\n\nfunction nodeTextValue(c: ElementContent): string {\n return c.type === 'text' ? (c as { value: string }).value : ''\n}\n\nfunction extractHeadingItem(node: Element): TocItem | null {\n const match = /^h([1-6])$/.exec(node.tagName)\n if (!match) return null\n const id = typeof node.properties?.id === 'string' ? node.properties.id : ''\n const text = node.children.map(nodeTextValue).join('')\n if (!id || !text) return null\n return { id, depth: Number.parseInt(match[1], 10), text }\n}\n\nexport async function extractToc(markdown: string): Promise<TocItem[]> {\n const headings: TocItem[] = []\n const collectHeadings: Plugin<[], Root> = () => (tree: Root) => {\n visit(tree, 'element', (node: Element) => {\n const item = extractHeadingItem(node)\n if (item) headings.push(item)\n })\n }\n await remark()\n .use(remarkParse)\n .use(remarkGfm)\n .use(remarkRehype)\n .use(rehypeSlug)\n .use(collectHeadings)\n .use(rehypeStringify)\n .process(markdown)\n return headings\n}\n","export type ArticlesErrorCode =\n | 'article-directory-read-failed'\n | 'article-load-failed'\n | 'article-markdown-load-failed'\n | 'markdown-conversion-failed'\n | 'unsafe-image-path'\n\nexport type ArticlesErrorContext = Readonly<Record<string, string | number | boolean | undefined>>\n\nexport type ArticlesErrorReport = Readonly<{\n code: ArticlesErrorCode\n message: string\n error?: unknown\n context?: ArticlesErrorContext\n}>\n\nexport type ArticlesErrorHandler = (report: ArticlesErrorReport) => void\n\nfunction defaultArticlesErrorHandler(report: ArticlesErrorReport): void {\n if (process.env.NODE_ENV === 'production') return\n const context = report.context ? ` ${JSON.stringify(report.context)}` : ''\n const detail = report.error instanceof Error ? `: ${report.error.message}` : ''\n console.warn(`[articles:${report.code}] ${report.message}${context}${detail}`)\n}\n\nlet articlesErrorHandler: ArticlesErrorHandler = defaultArticlesErrorHandler\n\nexport function setArticlesErrorHandler(handler?: ArticlesErrorHandler): void {\n articlesErrorHandler = handler ?? defaultArticlesErrorHandler\n}\n\nexport function reportArticlesError(report: ArticlesErrorReport): void {\n articlesErrorHandler(report)\n}\n","// Next.js API route handler factory — Node.js runtime only\n// Import from '@fullstackdatasolutions/articles/nextjs'\nimport type { NextRequest } from 'next/server'\nimport { getArticleMarkdownResponse } from './server-articles'\nimport type { ArticlesConfig } from './articlesConfig'\n\n/**\n * Returns a Next.js App Router GET handler for serving article markdown.\n *\n * Usage in app/api/articles-markdown/[...slug]/route.ts:\n * import { createArticleMarkdownHandler } from '@fullstackdatasolutions/articles/nextjs'\n * import { siteConfig } from '@/config/articles'\n * export const { GET } = createArticleMarkdownHandler(siteConfig)\n */\nexport function createArticleMarkdownHandler(config: ArticlesConfig) {\n async function GET(\n _request: NextRequest,\n context: { params: Promise<Record<string, string | string[]>> }\n ) {\n const params = await context.params\n const slugParts = params['slug']\n const slug = Array.isArray(slugParts) ? slugParts.join('/') : (slugParts ?? '')\n const cleanSlug = slug.endsWith('.md') ? slug.slice(0, -3) : slug\n return getArticleMarkdownResponse(cleanSlug, config)\n }\n return { GET }\n}\n"],"mappings":"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","names":["sanitizeImagePath"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fullstackdatasolutions/articles",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"funding": {
|
|
@@ -27,7 +27,20 @@
|
|
|
27
27
|
"json-ld",
|
|
28
28
|
"tailwindcss",
|
|
29
29
|
"content",
|
|
30
|
-
"static-site"
|
|
30
|
+
"static-site",
|
|
31
|
+
"shadcn",
|
|
32
|
+
"shadcn-ui",
|
|
33
|
+
"typescript",
|
|
34
|
+
"comments",
|
|
35
|
+
"social-share",
|
|
36
|
+
"structured-data",
|
|
37
|
+
"open-graph",
|
|
38
|
+
"table-of-contents",
|
|
39
|
+
"reading-time",
|
|
40
|
+
"ai",
|
|
41
|
+
"monorepo",
|
|
42
|
+
"next-auth",
|
|
43
|
+
"tailwind-v4"
|
|
31
44
|
],
|
|
32
45
|
"type": "module",
|
|
33
46
|
"exports": {
|
|
@@ -40,6 +53,16 @@
|
|
|
40
53
|
"import": "./dist/server.js",
|
|
41
54
|
"require": "./dist/server.cjs",
|
|
42
55
|
"types": "./dist/server.d.ts"
|
|
56
|
+
},
|
|
57
|
+
"./nextjs": {
|
|
58
|
+
"import": "./dist/nextjs.js",
|
|
59
|
+
"require": "./dist/nextjs.cjs",
|
|
60
|
+
"types": "./dist/nextjs.d.ts"
|
|
61
|
+
},
|
|
62
|
+
"./middleware": {
|
|
63
|
+
"import": "./dist/nextjs-middleware.js",
|
|
64
|
+
"require": "./dist/nextjs-middleware.cjs",
|
|
65
|
+
"types": "./dist/nextjs-middleware.d.ts"
|
|
43
66
|
}
|
|
44
67
|
},
|
|
45
68
|
"files": [
|
|
@@ -69,7 +92,7 @@
|
|
|
69
92
|
"rehype-stringify": "^10.0.1",
|
|
70
93
|
"remark": "^15.0.1",
|
|
71
94
|
"remark-gfm": "^4.0.1",
|
|
72
|
-
"remark-github-blockquote-alert": "^
|
|
95
|
+
"remark-github-blockquote-alert": "^2.1.0",
|
|
73
96
|
"remark-parse": "^11.0.0",
|
|
74
97
|
"remark-rehype": "^11.1.1",
|
|
75
98
|
"unified": "^11.0.5",
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for nextjs-middleware.ts (Edge-safe URL rewrite helper).
|
|
3
|
+
* Uses manual mocks for next/server to avoid Edge runtime dependencies.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Manual mock for next/server - must be before imports
|
|
7
|
+
const mockRewrite = jest.fn()
|
|
8
|
+
|
|
9
|
+
jest.mock('next/server', () => ({
|
|
10
|
+
NextResponse: {
|
|
11
|
+
rewrite: (url: URL) => mockRewrite(url),
|
|
12
|
+
},
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
rewriteArticleMarkdown,
|
|
17
|
+
middleware,
|
|
18
|
+
config,
|
|
19
|
+
createArticleMarkdownMiddleware,
|
|
20
|
+
} from '../nextjs-middleware'
|
|
21
|
+
|
|
22
|
+
type MiddlewareRequest = Parameters<typeof rewriteArticleMarkdown>[0]
|
|
23
|
+
|
|
24
|
+
function makeRequest(pathname: string, base = 'https://example.com'): MiddlewareRequest {
|
|
25
|
+
const request = {
|
|
26
|
+
nextUrl: { pathname },
|
|
27
|
+
url: `${base}${pathname}`,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return request as MiddlewareRequest
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('rewriteArticleMarkdown', () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
jest.clearAllMocks()
|
|
36
|
+
mockRewrite.mockImplementation((url: URL) => ({ type: 'rewrite', url }))
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('rewrites /articles/slug.md to /api/articles-markdown/slug.md', () => {
|
|
40
|
+
const req = makeRequest('/articles/some-article.md')
|
|
41
|
+
const result = rewriteArticleMarkdown(req)
|
|
42
|
+
|
|
43
|
+
expect(mockRewrite).toHaveBeenCalledTimes(1)
|
|
44
|
+
const calledUrl: URL = mockRewrite.mock.calls[0][0]
|
|
45
|
+
expect(calledUrl.pathname).toBe('/api/articles-markdown/some-article.md')
|
|
46
|
+
expect(result).toBeDefined()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('rewrites nested slug path correctly', () => {
|
|
50
|
+
const req = makeRequest('/articles/category/nested-article.md')
|
|
51
|
+
rewriteArticleMarkdown(req)
|
|
52
|
+
|
|
53
|
+
const calledUrl: URL = mockRewrite.mock.calls[0][0]
|
|
54
|
+
expect(calledUrl.pathname).toBe('/api/articles-markdown/category/nested-article.md')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('returns undefined for paths that do not start with /articles/', () => {
|
|
58
|
+
const req = makeRequest('/blog/some-article.md')
|
|
59
|
+
const result = rewriteArticleMarkdown(req)
|
|
60
|
+
|
|
61
|
+
expect(mockRewrite).not.toHaveBeenCalled()
|
|
62
|
+
expect(result).toBeUndefined()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('returns undefined for paths that start with /articles/ but do not end with .md', () => {
|
|
66
|
+
const req = makeRequest('/articles/some-article')
|
|
67
|
+
const result = rewriteArticleMarkdown(req)
|
|
68
|
+
|
|
69
|
+
expect(mockRewrite).not.toHaveBeenCalled()
|
|
70
|
+
expect(result).toBeUndefined()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('returns undefined for paths that end in .md but do not start with /articles/', () => {
|
|
74
|
+
const req = makeRequest('/docs/readme.md')
|
|
75
|
+
const result = rewriteArticleMarkdown(req)
|
|
76
|
+
|
|
77
|
+
expect(mockRewrite).not.toHaveBeenCalled()
|
|
78
|
+
expect(result).toBeUndefined()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('returns undefined for the root path', () => {
|
|
82
|
+
const req = makeRequest('/')
|
|
83
|
+
const result = rewriteArticleMarkdown(req)
|
|
84
|
+
|
|
85
|
+
expect(mockRewrite).not.toHaveBeenCalled()
|
|
86
|
+
expect(result).toBeUndefined()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('uses custom apiBase when provided', () => {
|
|
90
|
+
const req = makeRequest('/articles/my-article.md')
|
|
91
|
+
rewriteArticleMarkdown(req, '/custom/api/base')
|
|
92
|
+
|
|
93
|
+
const calledUrl: URL = mockRewrite.mock.calls[0][0]
|
|
94
|
+
expect(calledUrl.pathname).toBe('/custom/api/base/my-article.md')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('defaults to /api/articles-markdown when apiBase is omitted', () => {
|
|
98
|
+
const req = makeRequest('/articles/test.md')
|
|
99
|
+
rewriteArticleMarkdown(req)
|
|
100
|
+
|
|
101
|
+
const calledUrl: URL = mockRewrite.mock.calls[0][0]
|
|
102
|
+
expect(calledUrl.pathname).toBe('/api/articles-markdown/test.md')
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe('middleware (legacy export)', () => {
|
|
107
|
+
beforeEach(() => {
|
|
108
|
+
jest.clearAllMocks()
|
|
109
|
+
mockRewrite.mockImplementation((url: URL) => ({ type: 'rewrite', url }))
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('is a function', () => {
|
|
113
|
+
expect(typeof middleware).toBe('function')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('rewrites .md article paths', () => {
|
|
117
|
+
const req = makeRequest('/articles/legacy.md')
|
|
118
|
+
const result = middleware(req)
|
|
119
|
+
|
|
120
|
+
expect(mockRewrite).toHaveBeenCalledTimes(1)
|
|
121
|
+
expect(result).toBeDefined()
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('returns undefined for non-article paths', () => {
|
|
125
|
+
const req = makeRequest('/about')
|
|
126
|
+
const result = middleware(req)
|
|
127
|
+
|
|
128
|
+
expect(mockRewrite).not.toHaveBeenCalled()
|
|
129
|
+
expect(result).toBeUndefined()
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe('config export', () => {
|
|
134
|
+
it('exports a matcher for article .md paths', () => {
|
|
135
|
+
expect(config).toEqual({
|
|
136
|
+
matcher: '/articles/:path*.md',
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe('createArticleMarkdownMiddleware', () => {
|
|
142
|
+
beforeEach(() => {
|
|
143
|
+
jest.clearAllMocks()
|
|
144
|
+
mockRewrite.mockImplementation((url: URL) => ({ type: 'rewrite', url }))
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('returns an object with middleware and config', () => {
|
|
148
|
+
const result = createArticleMarkdownMiddleware()
|
|
149
|
+
expect(typeof result.middleware).toBe('function')
|
|
150
|
+
expect(result.config).toEqual({ matcher: '/articles/:path*.md' })
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('middleware uses the default apiBase when none provided', () => {
|
|
154
|
+
const { middleware: mw } = createArticleMarkdownMiddleware()
|
|
155
|
+
const req = makeRequest('/articles/test.md')
|
|
156
|
+
mw(req)
|
|
157
|
+
|
|
158
|
+
const calledUrl: URL = mockRewrite.mock.calls[0][0]
|
|
159
|
+
expect(calledUrl.pathname).toBe('/api/articles-markdown/test.md')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('middleware uses the custom apiBase when provided', () => {
|
|
163
|
+
const { middleware: mw } = createArticleMarkdownMiddleware('/v2/articles-api')
|
|
164
|
+
const req = makeRequest('/articles/test.md')
|
|
165
|
+
mw(req)
|
|
166
|
+
|
|
167
|
+
const calledUrl: URL = mockRewrite.mock.calls[0][0]
|
|
168
|
+
expect(calledUrl.pathname).toBe('/v2/articles-api/test.md')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('middleware returns undefined for non-matching paths', () => {
|
|
172
|
+
const { middleware: mw } = createArticleMarkdownMiddleware()
|
|
173
|
+
const req = makeRequest('/about')
|
|
174
|
+
const result = mw(req)
|
|
175
|
+
|
|
176
|
+
expect(result).toBeUndefined()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('config always contains the standard matcher regardless of apiBase', () => {
|
|
180
|
+
const { config: cfg } = createArticleMarkdownMiddleware('/custom')
|
|
181
|
+
expect(cfg).toEqual({ matcher: '/articles/:path*.md' })
|
|
182
|
+
})
|
|
183
|
+
})
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for nextjs.ts (createArticleMarkdownHandler factory).
|
|
3
|
+
* Mocks server-articles to isolate the handler logic.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const mockGetArticleMarkdownResponse = jest.fn()
|
|
7
|
+
|
|
8
|
+
jest.mock('../server-articles', () => ({
|
|
9
|
+
getArticleMarkdownResponse: (...args: unknown[]) => mockGetArticleMarkdownResponse(...args),
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
import { createArticleMarkdownHandler } from '../nextjs'
|
|
13
|
+
import type { ArticlesConfig } from '../articlesConfig'
|
|
14
|
+
import type { NextRequest } from 'next/server'
|
|
15
|
+
|
|
16
|
+
// jsdom does not include Response - polyfill to match server-articles.test.ts pattern
|
|
17
|
+
class TestResponse {
|
|
18
|
+
readonly status: number
|
|
19
|
+
constructor(
|
|
20
|
+
private readonly body: string,
|
|
21
|
+
init?: { status?: number }
|
|
22
|
+
) {
|
|
23
|
+
this.status = init?.status ?? 200
|
|
24
|
+
}
|
|
25
|
+
text(): Promise<string> {
|
|
26
|
+
return Promise.resolve(this.body)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (globalThis.Response === undefined) {
|
|
31
|
+
Object.defineProperty(globalThis, 'Response', {
|
|
32
|
+
value: TestResponse,
|
|
33
|
+
configurable: true,
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const siteConfig: ArticlesConfig = {
|
|
38
|
+
siteUrl: 'https://example.com',
|
|
39
|
+
siteName: 'Example Site',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeContext(slugValue: string | string[]): {
|
|
43
|
+
params: Promise<Record<string, string | string[]>>
|
|
44
|
+
} {
|
|
45
|
+
return {
|
|
46
|
+
params: Promise.resolve({ slug: slugValue }),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function makeRequest(): NextRequest {
|
|
51
|
+
return {} as NextRequest
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('createArticleMarkdownHandler', () => {
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
jest.clearAllMocks()
|
|
57
|
+
mockGetArticleMarkdownResponse.mockResolvedValue(new Response('# Article', { status: 200 }))
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('returns an object with a GET handler', () => {
|
|
61
|
+
const { GET } = createArticleMarkdownHandler(siteConfig)
|
|
62
|
+
expect(typeof GET).toBe('function')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('calls getArticleMarkdownResponse with joined slug array and config', async () => {
|
|
66
|
+
const { GET } = createArticleMarkdownHandler(siteConfig)
|
|
67
|
+
const ctx = makeContext(['category', 'my-article.md'])
|
|
68
|
+
await GET(makeRequest(), ctx)
|
|
69
|
+
|
|
70
|
+
expect(mockGetArticleMarkdownResponse).toHaveBeenCalledWith('category/my-article', siteConfig)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('strips trailing .md from a slug array', async () => {
|
|
74
|
+
const { GET } = createArticleMarkdownHandler(siteConfig)
|
|
75
|
+
const ctx = makeContext(['some-article.md'])
|
|
76
|
+
await GET(makeRequest(), ctx)
|
|
77
|
+
|
|
78
|
+
expect(mockGetArticleMarkdownResponse).toHaveBeenCalledWith('some-article', siteConfig)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('handles a plain string slug without .md extension', async () => {
|
|
82
|
+
const { GET } = createArticleMarkdownHandler(siteConfig)
|
|
83
|
+
const ctx = makeContext('plain-slug')
|
|
84
|
+
await GET(makeRequest(), ctx)
|
|
85
|
+
|
|
86
|
+
expect(mockGetArticleMarkdownResponse).toHaveBeenCalledWith('plain-slug', siteConfig)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('handles a plain string slug with .md extension - strips it', async () => {
|
|
90
|
+
const { GET } = createArticleMarkdownHandler(siteConfig)
|
|
91
|
+
const ctx = makeContext('plain-slug.md')
|
|
92
|
+
await GET(makeRequest(), ctx)
|
|
93
|
+
|
|
94
|
+
expect(mockGetArticleMarkdownResponse).toHaveBeenCalledWith('plain-slug', siteConfig)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('handles undefined slug (missing param) as empty string', async () => {
|
|
98
|
+
const { GET } = createArticleMarkdownHandler(siteConfig)
|
|
99
|
+
const ctx = {
|
|
100
|
+
params: Promise.resolve({} as Record<string, string | string[]>),
|
|
101
|
+
}
|
|
102
|
+
await GET(makeRequest(), ctx)
|
|
103
|
+
|
|
104
|
+
expect(mockGetArticleMarkdownResponse).toHaveBeenCalledWith('', siteConfig)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('returns the response from getArticleMarkdownResponse', async () => {
|
|
108
|
+
const mockResponse = new Response('markdown content', { status: 200 })
|
|
109
|
+
mockGetArticleMarkdownResponse.mockResolvedValue(mockResponse)
|
|
110
|
+
|
|
111
|
+
const { GET } = createArticleMarkdownHandler(siteConfig)
|
|
112
|
+
const ctx = makeContext(['article.md'])
|
|
113
|
+
const result = await GET(makeRequest(), ctx)
|
|
114
|
+
|
|
115
|
+
expect(result).toBe(mockResponse)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('passes the config object through to getArticleMarkdownResponse', async () => {
|
|
119
|
+
const customConfig: ArticlesConfig = {
|
|
120
|
+
siteUrl: 'https://custom.example.com',
|
|
121
|
+
siteName: 'Custom Site',
|
|
122
|
+
}
|
|
123
|
+
const { GET } = createArticleMarkdownHandler(customConfig)
|
|
124
|
+
const ctx = makeContext(['test.md'])
|
|
125
|
+
await GET(makeRequest(), ctx)
|
|
126
|
+
|
|
127
|
+
expect(mockGetArticleMarkdownResponse).toHaveBeenCalledWith('test', customConfig)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('handles deeply nested slug array', async () => {
|
|
131
|
+
const { GET } = createArticleMarkdownHandler(siteConfig)
|
|
132
|
+
const ctx = makeContext(['a', 'b', 'c', 'deep-article.md'])
|
|
133
|
+
await GET(makeRequest(), ctx)
|
|
134
|
+
|
|
135
|
+
expect(mockGetArticleMarkdownResponse).toHaveBeenCalledWith('a/b/c/deep-article', siteConfig)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Next.js proxy/middleware helper — Edge runtime safe (no Node.js built-ins)
|
|
2
|
+
// Import from '@fullstackdatasolutions/articles/middleware'
|
|
3
|
+
import { NextResponse } from 'next/server'
|
|
4
|
+
import type { NextRequest } from 'next/server'
|
|
5
|
+
|
|
6
|
+
const DEFAULT_API_BASE = '/api/articles-markdown'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Rewrite /articles/*.md requests to the markdown API route.
|
|
10
|
+
* Returns a NextResponse rewrite if the request matches, undefined otherwise.
|
|
11
|
+
*
|
|
12
|
+
* Usage in proxy.ts (Next.js 16+):
|
|
13
|
+
* import { rewriteArticleMarkdown } from '@fullstackdatasolutions/articles/middleware'
|
|
14
|
+
* export async function proxy(request: NextRequest) {
|
|
15
|
+
* return rewriteArticleMarkdown(request) ?? NextResponse.next()
|
|
16
|
+
* }
|
|
17
|
+
*/
|
|
18
|
+
export function rewriteArticleMarkdown(
|
|
19
|
+
request: NextRequest,
|
|
20
|
+
apiBase = DEFAULT_API_BASE
|
|
21
|
+
): NextResponse | undefined {
|
|
22
|
+
const { pathname } = request.nextUrl
|
|
23
|
+
if (pathname.startsWith('/articles/') && pathname.endsWith('.md')) {
|
|
24
|
+
const slug = pathname.slice('/articles/'.length)
|
|
25
|
+
return NextResponse.rewrite(new URL(`${apiBase}/${slug}`, request.url))
|
|
26
|
+
}
|
|
27
|
+
return undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Legacy middleware.ts usage (Next.js 14/15)
|
|
31
|
+
function buildMiddleware(apiBase: string) {
|
|
32
|
+
return function middleware(request: NextRequest) {
|
|
33
|
+
return rewriteArticleMarkdown(request, apiBase)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const middleware = buildMiddleware(DEFAULT_API_BASE)
|
|
38
|
+
|
|
39
|
+
export const config = {
|
|
40
|
+
matcher: '/articles/:path*.md',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createArticleMarkdownMiddleware(apiBase = DEFAULT_API_BASE) {
|
|
44
|
+
return {
|
|
45
|
+
middleware: buildMiddleware(apiBase),
|
|
46
|
+
config: { matcher: '/articles/:path*.md' },
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/nextjs.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Next.js API route handler factory — Node.js runtime only
|
|
2
|
+
// Import from '@fullstackdatasolutions/articles/nextjs'
|
|
3
|
+
import type { NextRequest } from 'next/server'
|
|
4
|
+
import { getArticleMarkdownResponse } from './server-articles'
|
|
5
|
+
import type { ArticlesConfig } from './articlesConfig'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Returns a Next.js App Router GET handler for serving article markdown.
|
|
9
|
+
*
|
|
10
|
+
* Usage in app/api/articles-markdown/[...slug]/route.ts:
|
|
11
|
+
* import { createArticleMarkdownHandler } from '@fullstackdatasolutions/articles/nextjs'
|
|
12
|
+
* import { siteConfig } from '@/config/articles'
|
|
13
|
+
* export const { GET } = createArticleMarkdownHandler(siteConfig)
|
|
14
|
+
*/
|
|
15
|
+
export function createArticleMarkdownHandler(config: ArticlesConfig) {
|
|
16
|
+
async function GET(
|
|
17
|
+
_request: NextRequest,
|
|
18
|
+
context: { params: Promise<Record<string, string | string[]>> }
|
|
19
|
+
) {
|
|
20
|
+
const params = await context.params
|
|
21
|
+
const slugParts = params['slug']
|
|
22
|
+
const slug = Array.isArray(slugParts) ? slugParts.join('/') : (slugParts ?? '')
|
|
23
|
+
const cleanSlug = slug.endsWith('.md') ? slug.slice(0, -3) : slug
|
|
24
|
+
return getArticleMarkdownResponse(cleanSlug, config)
|
|
25
|
+
}
|
|
26
|
+
return { GET }
|
|
27
|
+
}
|