diff --git a/src/lib/cms/content-processor.js b/src/lib/cms/content-processor.js index bbfd8ae1..7c4798a1 100644 --- a/src/lib/cms/content-processor.js +++ b/src/lib/cms/content-processor.js @@ -29,7 +29,7 @@ try { // Configure mdsvex options const mdsvexOptions = { - extensions: ['.md'], + extensions: ['.md', '.txt'], remarkPlugins: [remarkGfm], rehypePlugins: [rehypeSlug], layout: null // We'll handle layout in Svelte components @@ -71,6 +71,83 @@ const processMarkdownWithMDSvex = async (markdown) => { } }; +/** + * Process plain text content and convert to HTML paragraphs + * Supports markdown-style images and links while keeping everything else as plain text + * Treats double line breaks as paragraph boundaries + */ +const processPlainText = (text) => { + // Escape HTML characters for safety + const escapeHtml = (str) => { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + }; + + // Step 1: Extract markdown images and links before escaping + const placeholders = []; + let textWithPlaceholders = text; + + // Extract images: ![alt](src) + const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; + textWithPlaceholders = textWithPlaceholders.replace(imageRegex, (match, alt, src) => { + const id = `__IMG_PLACEHOLDER_${placeholders.length}__`; + placeholders.push({ + type: 'img', + alt: alt || '', + src: src.trim(), + id + }); + return id; + }); + + // Extract links: [text](url) + const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; + textWithPlaceholders = textWithPlaceholders.replace(linkRegex, (match, text, href) => { + const id = `__LINK_PLACEHOLDER_${placeholders.length}__`; + placeholders.push({ + type: 'link', + text: text, + href: href.trim(), + id + }); + return id; + }); + + // Step 2: Split on double newlines to get paragraphs + const paragraphs = textWithPlaceholders + .split(/\n\s*\n/) + .map(para => para.trim()) + .filter(para => para.length > 0); + + // Step 3: Wrap each paragraph in

tags, preserving single line breaks as
+ const html = paragraphs + .map(para => { + const escapedPara = escapeHtml(para); + // Convert single line breaks to
tags + const withBreaks = escapedPara.replace(/\n/g, '
'); + return `

${withBreaks}

`; + }) + .join('\n'); + + // Step 4: Replace placeholders with actual HTML tags + let finalHtml = html; + placeholders.forEach(item => { + if (item.type === 'img') { + const imgTag = `${escapeHtml(item.alt)}`; + finalHtml = finalHtml.replace(item.id, imgTag); + } else if (item.type === 'link') { + const linkTag = `${escapeHtml(item.text)}`; + finalHtml = finalHtml.replace(item.id, linkTag); + } + }); + + return finalHtml; +}; + // Function to remove the first h1 heading from HTML content const removeFirstH1 = (html) => { return html.replace(/]*>(.*?)<\/h1>/, ''); @@ -128,47 +205,88 @@ const scanContentDirectory = async () => { if (stats.isDirectory()) { // If it's a folder, scan its contents await scanDir(fullPath, entryRelativePath); - } else if (stats.isFile() && (entry.endsWith('.md') || entry.endsWith('.mdx'))) { - // Add markdown and mdx files to the list + } else if (stats.isFile() && (entry.endsWith('.md') || entry.endsWith('.mdx') || entry.endsWith('.txt'))) { + // Check if this is a gallery metadata .txt file (paired with an image) + const isGalleryMetadata = entry.endsWith('.txt') && (() => { + const basename = entry.slice(0, -4); // Remove .txt + const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; + const dirEntries = fs.readdirSync(dirPath); + return imageExtensions.some(ext => dirEntries.includes(basename + ext)); + })(); + + // Skip gallery metadata .txt files + if (isGalleryMetadata) { + continue; + } + + // Add markdown, mdx, and text files to the list const isMdx = entry.endsWith('.mdx'); - const slug = entry.replace(/\.mdx?$/, ''); + const isTxtFile = entry.endsWith('.txt'); + const slug = entry.replace(/\.(mdx?|txt)$/, ''); const url = relativePath ? `/${relativePath}/${slug}`.replace(/\\/g, '/') : `/${slug}`; - const content = fs.readFileSync(fullPath, 'utf-8'); - const { data, content: markdownContent } = matter(content); - - // Process template variables (both in markdown content and metadata) - const processedMarkdownContent = processTemplateVariables(markdownContent); - const processedMetadata = {}; + const fileContent = fs.readFileSync(fullPath, 'utf-8'); + + let html; + let finalMetadata; + + if (isTxtFile) { + // For .txt files: no frontmatter, pure plain text + const processedContent = processTemplateVariables(fileContent); + html = processPlainText(processedContent); + + // Extract first image from content as thumbnail + const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/; + const imageMatch = processedContent.match(imageRegex); + const thumbnail = imageMatch ? imageMatch[2].trim() : null; + + finalMetadata = { + title: formatTitle(slug), + description: '', + date: null, + author: null, + thumbnail: thumbnail + }; + } else { + // For .md and .mdx files: extract frontmatter and process markdown + const { data, content: markdownContent } = matter(fileContent); + + // Process template variables (both in markdown content and metadata) + const processedMarkdownContent = processTemplateVariables(markdownContent); + const processedMetadata = {}; + + // Process string values in metadata through template processing + for (const [key, value] of Object.entries(data)) { + if (typeof value === 'string') { + processedMetadata[key] = processTemplateVariables(value); + } else { + processedMetadata[key] = value; + } + } - // Process string values in metadata through template processing - for (const [key, value] of Object.entries(data)) { - if (typeof value === 'string') { - processedMetadata[key] = processTemplateVariables(value); - } else { - processedMetadata[key] = value; + // Add default values and process them through template processing + finalMetadata = { + title: processedMetadata.title || formatTitle(slug), + description: processedMetadata.description || '', + date: processedMetadata.date || null, + author: processedMetadata.author || null, + ...processedMetadata + }; + + // Process content: MDX files are rendered as Svelte components, MD files as HTML + if (!isMdx) { + html = await processMarkdownWithMDSvex(processedMarkdownContent); + html = removeFirstH1(html); } } - // Add default values and process them through template processing - const finalMetadata = { - title: processedMetadata.title || formatTitle(slug), - description: processedMetadata.description || '', - date: processedMetadata.date || null, - author: processedMetadata.author || null, - ...processedMetadata - }; - // Fix directory - use full path let directory = relativePath.replace(/\\/g, '/'); - // Process content: MDX files are rendered as Svelte components, MD files as HTML - let html = ''; + // Transform links for .md and .txt files (not MDX) if (!isMdx) { - html = await processMarkdownWithMDSvex(processedMarkdownContent); - html = removeFirstH1(html); html = transformLinks(html, directory); } @@ -223,6 +341,54 @@ const getContentDirectories = () => { return directories; }; +/** + * Get root-level content files (not in subdirectories) + * Returns array of content objects with name, path, title, and url properties + * Excludes gallery metadata .txt files (those paired with an image file) + */ +const getRootLevelContent = () => { + const contentPath = path.resolve('content'); + const rootFiles = []; + + if (!fs.existsSync(contentPath)) { + console.warn('Content folder not found!'); + return rootFiles; + } + + const entries = fs.readdirSync(contentPath); + + for (const entry of entries) { + const fullPath = path.join(contentPath, entry); + const stats = fs.statSync(fullPath); + + // Only process files (not directories) with .md or .txt extension + if (stats.isFile() && (entry.endsWith('.md') || entry.endsWith('.txt'))) { + // Check if this is a gallery metadata .txt file (paired with an image) + const isGalleryMetadata = entry.endsWith('.txt') && (() => { + const basename = entry.slice(0, -4); // Remove .txt + const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; + return imageExtensions.some(ext => entries.includes(basename + ext)); + })(); + + // Skip gallery metadata files + if (isGalleryMetadata) { + continue; + } + + const slug = entry.replace(/\.(md|txt)$/, ''); + + rootFiles.push({ + name: slug, + path: `content/${entry}`, + title: formatTitle(slug), + url: `/${slug}` + }); + } + } + + return rootFiles; +}; + // Function to create a title from a slug const formatTitle = (slug) => { return slug @@ -479,10 +645,110 @@ const getAllDirectoriesSidebar = async () => { return result; }; +/** + * Detect naming conflicts between directories and files + * Returns array of conflicts found + */ +const detectNamingConflicts = async () => { + const directories = getContentDirectories(); + const allContent = await getAllContent(); + const conflicts = []; + + // Check 1: Directory vs File conflicts + for (const dir of directories) { + // Check if there's a root-level file with same name as directory + const conflictingFiles = allContent.filter(content => { + // Only check root-level files (no slash in directory property or directory is empty) + const isRootLevel = !content.directory || content.directory === 'root'; + + // Extract filename without extension from the path + const fileName = content.path.split('/').pop().replace(/\.(md|txt)$/, ''); + + return isRootLevel && fileName === dir.name; + }); + + if (conflictingFiles.length > 0) { + conflicts.push({ + type: 'directory-file', + name: dir.name, + directory: `content/${dir.name}/`, + files: conflictingFiles.map(f => f.path) + }); + } + } + + // Check 2: File vs File conflicts (same name, different extensions) + const rootLevelFiles = allContent.filter(content => { + const isRootLevel = !content.directory || content.directory === 'root'; + return isRootLevel; + }); + + // Group files by their slug (name without extension) + const filesBySlug = {}; + rootLevelFiles.forEach(content => { + const fileName = content.path.split('/').pop().replace(/\.(md|txt)$/, ''); + if (!filesBySlug[fileName]) { + filesBySlug[fileName] = []; + } + filesBySlug[fileName].push(content.path); + }); + + // Find slugs with multiple files + Object.entries(filesBySlug).forEach(([slug, files]) => { + if (files.length > 1) { + conflicts.push({ + type: 'file-file', + name: slug, + files: files + }); + } + }); + + return conflicts; +}; + +/** + * Check for conflicts and handle based on environment + * - Development: Log warning, allow server to continue + * - Production build: Throw error, block build + */ +const checkNamingConflicts = async () => { + const conflicts = await detectNamingConflicts(); + + if (conflicts.length === 0) { + return { hasConflicts: false, conflicts: [] }; + } + + // Check if we're in production build mode + const isProduction = process.env.NODE_ENV === 'production'; + + conflicts.forEach(conflict => { + const fileList = conflict.files.map(f => ` - ${f}`).join('\n'); + + const directoryLine = conflict.type === 'directory-file' ? ` - ${conflict.directory} (directory)\n` : ''; + const message = `⚠️ WARNING: Naming conflict detected for "${conflict.name}"\n Found multiple items with the same name:\n${directoryLine}${fileList}\n\n Only one can be accessible at /${conflict.name}/\n Please rename one to resolve this conflict.\n`; + + if (isProduction) { + // In production, throw error to block build + console.error(`❌ ${message}`); + } else { + // In development, just warn + console.warn(message); + } + }); + + if (isProduction) { + throw new Error(`Build failed: ${conflicts.length} naming conflict(s) detected. Fix conflicts before building for production.`); + } + + return { hasConflicts: true, conflicts }; +}; + // Export functions export { scanContentDirectory, getContentDirectories, + getRootLevelContent, formatTitle, getAllContent, getContentByUrl, @@ -491,5 +757,7 @@ export { getSubDirectories, processTemplateVariables, getSidebarTree, - getAllDirectoriesSidebar + getAllDirectoriesSidebar, + detectNamingConflicts, + checkNamingConflicts }; diff --git a/src/lib/components/NavigationBar.svelte b/src/lib/components/NavigationBar.svelte index f4c28a9a..56efa300 100644 --- a/src/lib/components/NavigationBar.svelte +++ b/src/lib/components/NavigationBar.svelte @@ -19,6 +19,10 @@ cta?: boolean; name?: string; }>; + rootContent?: Array<{ + title: string; + url: string; + }>; activePath?: string; showSearch?: boolean; searchPlaceholder?: string; @@ -36,6 +40,7 @@ siteName = 'Site', items = [], navbarItems = [], + rootContent = [], activePath = '', showSearch = false, searchPlaceholder = "Search...", @@ -130,6 +135,15 @@ {/if} {/each} + {#each rootContent as item} + + {item.title} + + {/each} + {#each filteredNavbarItems as item} {#if !item.cta} + {item.title} + + {/each} + {#each filteredNavbarItems as item} {#if !item.cta} { @@ -28,6 +31,7 @@ export async function load() { return { globalDirectories: enhancedDirectories, + rootContent, searchConfig: siteConfig.search || null, navbarConfig: siteConfig.navbar || null, rssEnabled: siteConfig.rss?.enabled ?? false, diff --git a/templates/default/src/routes/[directory]/+page.server.js b/templates/default/src/routes/[directory]/+page.server.js index 389c6727..28dffa92 100644 --- a/templates/default/src/routes/[directory]/+page.server.js +++ b/templates/default/src/routes/[directory]/+page.server.js @@ -1,16 +1,55 @@ -import { getContentDirectories, getContentByDirectory, getSubDirectories, getSidebarTree } from 'statue-ssg/cms/content-processor'; +import { getContentDirectories, getContentByDirectory, getSubDirectories, getSidebarTree, getContentByUrl, detectNamingConflicts, clearContentCache } from 'statue-ssg/cms/content-processor'; +import { error } from '@sveltejs/kit'; // Make this page prerendered as a static page export const prerender = true; /** @type {import('./$types').PageServerLoad} */ export async function load({ params }) { + // Clear cache to ensure fresh conflict detection + clearContentCache(); + // Get directory name const directoryName = params.directory; // Get all directories const directories = getContentDirectories(); + // Check for naming conflicts + const conflicts = await detectNamingConflicts(); + const hasConflict = conflicts.find(c => c.name === directoryName); + + // If this directory has a naming conflict, show error page + if (hasConflict) { + return { + hasNamingConflict: true, + conflictData: hasConflict, + directories + }; + } + + // Check if this is an actual directory + const isActualDirectory = directories.some(dir => dir.name === directoryName); + + if (isActualDirectory) { + // This IS a real directory - render directory listing + // Continue to directory handling below... + } else { + // NOT a directory - check if it's a root-level content file + const content = await getContentByUrl(`/${directoryName}`); + + if (content) { + console.log(`📄 Rendering content file: ${content.path}`); + + const sidebarItems = content.directory?.startsWith('docs') ? await getSidebarTree('docs') : []; + return { + content, + directories, + sidebarItems + }; + } + } + // Get content from specific directory (including content from subdirectories) const directoryContent = await getContentByDirectory(directoryName); diff --git a/templates/default/src/routes/[directory]/+page.svelte b/templates/default/src/routes/[directory]/+page.svelte index 02a923c7..e5c808f9 100644 --- a/templates/default/src/routes/[directory]/+page.svelte +++ b/templates/default/src/routes/[directory]/+page.svelte @@ -5,38 +5,110 @@ DirectoryContent, DocsLayout, DocsDirectoryList, - BlogLayout + BlogLayout, + ContentHeader, + ContentBody } from 'statue-ssg'; const { data } = $props(); - const isDocsDirectory = $derived(data.currentDirectory.name === 'docs'); - const isBlogDirectory = $derived(data.currentDirectory.name === 'blog'); + // Check if this is a root-level content file + const isRootContentFile = $derived(!!data.content); + + // Check if there's a naming conflict + const hasNamingConflict = $derived(!!data.hasNamingConflict); + + const isDocsDirectory = $derived(data.currentDirectory?.name === 'docs'); + const isBlogDirectory = $derived(data.currentDirectory?.name === 'blog'); const currentDirContent = $derived( - data.directoryContent.filter((page) => { - return page.directory === data.currentDirectory.name; - }) + data.directoryContent?.filter((page) => { + return page.directory === data.currentDirectory?.name; + }) || [] ); const subDirContent = $derived( - data.directoryContent.filter((page) => { + data.directoryContent?.filter((page) => { return ( - page.directory !== data.currentDirectory.name && - page.directory.startsWith(data.currentDirectory.name + '/') + page.directory !== data.currentDirectory?.name && + page.directory.startsWith(data.currentDirectory?.name + '/') ); - }) + }) || [] ); const allDocsContent = $derived([...currentDirContent, ...subDirContent]); - {data.currentDirectory.title} - + {data.content?.metadata?.title || data.currentDirectory?.title || 'Content'} + -{#if isDocsDirectory} +{#if hasNamingConflict} + +
+
+
+
+

+ ⚠️ Configuration Error +

+

+ Multiple items found for this path. Please check your content directory: +

+ +
+ {#if data.conflictData.type === 'directory-file'} +
Directory:
+
+ • {data.conflictData.directory} +
+ +
Files:
+ {#each data.conflictData.files as file} +
• {file}
+ {/each} + {:else if data.conflictData.type === 'file-file'} +
Conflicting Files:
+ {#each data.conflictData.files as file} +
• {file}
+ {/each} + {/if} +
+ +

+ To fix this: Rename one of these items to resolve the conflict. +

+ +

+ Check your terminal/console for detailed warnings about this conflict. +

+
+
+
+
+{:else if isRootContentFile} + + {#if data.content.directory?.startsWith('docs')} + + + + + {:else} +
+
+ + +
+
+ {/if} +{:else if isDocsDirectory}