Enhance StructureView to normalize folder paths and improve page assignment logic

This commit is contained in:
Elio Struyf
2025-09-09 19:14:38 +02:00
parent cb42bd4b4b
commit cda217ac76
3 changed files with 87 additions and 44 deletions

View File

@@ -4,6 +4,7 @@ import * as React from 'react';
import { useMemo } from 'react';
import { Page } from '../../models';
import { StructureItem } from './StructureItem';
import { parseWinPath } from '../../../helpers/parseWinPath';
export interface IStructureViewProps {
pages: Page[];
@@ -19,7 +20,7 @@ interface FolderNode {
export const StructureView: React.FunctionComponent<IStructureViewProps> = ({
pages
}: React.PropsWithChildren<IStructureViewProps>) => {
const folderTree = useMemo(() => {
const root: FolderNode = {
name: '',
@@ -31,23 +32,63 @@ export const StructureView: React.FunctionComponent<IStructureViewProps> = ({
const folderMap = new Map<string, FolderNode>();
folderMap.set('', root);
// First pass: create all folder nodes
pages.forEach(page => {
// Helper to compute the normalized folder path for a page.
// It ensures the page's folder starts with the `fmFolder` segment and
// preserves any subpaths after that segment (so subfolders are created).
const computeNormalizedFolderPath = (page: Page): string => {
if (!page.fmFolder) {
return;
return '';
}
const folderPath = page.fmFolder;
// Normalize path separators and remove leading/trailing slashes
const normalizedPath = folderPath.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
const fmFolder = page.fmFolder.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
// If we have a file path, use its directory (exclude the filename) to compute
// the relative path. This avoids treating filenames as folder segments.
const filePath = page.fmFilePath ? parseWinPath(page.fmFilePath).replace(/^\/+|\/+$/g, '') : '';
const fileDir = filePath && filePath.includes('/') ? filePath.substring(0, filePath.lastIndexOf('/')).replace(/^\/+|\/+$/g, '') : '';
if (fileDir) {
// If the content folder is known, and the file directory starts with it,
// replace that root with the fmFolder (preserving subfolders after it).
if (page.fmPageFolder?.path) {
const contentFolderPath = parseWinPath(page.fmPageFolder.path).replace(/^\/+|\/+$/g, '');
if (fileDir.startsWith(contentFolderPath)) {
const rel = fileDir.substring(contentFolderPath.length).replace(/^\/+|\/+$/g, '');
return rel ? `${fmFolder}/${rel}` : fmFolder;
}
}
// Otherwise try to find fmFolder as a directory segment in the fileDir
const segments = fileDir.split('/').filter(Boolean);
const fmIndex = segments.indexOf(fmFolder);
if (fmIndex >= 0) {
return segments.slice(fmIndex).join('/');
}
}
// Fallback: just use the fmFolder name
return fmFolder;
};
// First pass: create all folder nodes (ensure nodes exist even if a page lacks fmFilePath)
for (const page of pages) {
if (!page.fmFolder) {
continue;
}
const normalizedPath = computeNormalizedFolderPath(page).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
if (!normalizedPath) {
continue;
}
const parts = normalizedPath.split('/').filter(part => part.length > 0);
let currentPath = '';
let currentNode = root;
parts.forEach(part => {
for (const part of parts) {
const fullPath = currentPath ? `${currentPath}/${part}` : part;
if (!folderMap.has(fullPath)) {
const newNode: FolderNode = {
name: part,
@@ -58,31 +99,31 @@ export const StructureView: React.FunctionComponent<IStructureViewProps> = ({
folderMap.set(fullPath, newNode);
currentNode.children.push(newNode);
}
const nextNode = folderMap.get(fullPath);
if (nextNode) {
currentNode = nextNode;
}
currentPath = fullPath;
});
});
}
}
// Second pass: assign pages to their folders
pages.forEach(page => {
// Second pass: assign pages to their exact folder node (including subfolders)
for (const page of pages) {
if (!page.fmFolder) {
root.pages.push(page);
} else {
// Normalize the folder path for lookup
const normalizedPath = page.fmFolder.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
const folderNode = folderMap.get(normalizedPath);
if (folderNode) {
folderNode.pages.push(page);
} else {
// If folder not found, add to root as fallback
root.pages.push(page);
}
continue;
}
});
const normalizedPath = computeNormalizedFolderPath(page).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
const folderNode = normalizedPath ? folderMap.get(normalizedPath) : folderMap.get(page.fmFolder.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''));
if (folderNode) {
folderNode.pages.push(page);
} else {
// If folder not found, add to root as fallback
root.pages.push(page);
}
}
// Sort folders and pages
const sortNode = (node: FolderNode) => {
@@ -90,15 +131,15 @@ export const StructureView: React.FunctionComponent<IStructureViewProps> = ({
node.pages.sort((a, b) => a.title.localeCompare(b.title));
node.children.forEach(sortNode);
};
sortNode(root);
return root;
}, [pages]);
const renderFolderNode = (node: FolderNode, depth = 0): React.ReactNode => {
const hasContent = node.pages.length > 0 || node.children.length > 0;
if (!hasContent) {
return null;
}
@@ -110,6 +151,9 @@ export const StructureView: React.FunctionComponent<IStructureViewProps> = ({
// For root node, render children and pages directly
return (
<div>
{/* Root level folders */}
{node.children.map(child => renderFolderNode(child, depth + 1))}
{/* Root level pages */}
{node.pages.length > 0 && (
<div className="mb-6">
@@ -125,9 +169,6 @@ export const StructureView: React.FunctionComponent<IStructureViewProps> = ({
</ul>
</div>
)}
{/* Root level folders */}
{node.children.map(child => renderFolderNode(child, depth + 1))}
</div>
);
}
@@ -137,14 +178,13 @@ export const StructureView: React.FunctionComponent<IStructureViewProps> = ({
<Disclosure defaultOpen={depth <= 1}>
{({ open }) => (
<>
<Disclosure.Button
<Disclosure.Button
className="flex items-center w-full text-left"
style={{ paddingLeft: `${paddingLeft}px` }}
>
<ChevronRightIcon
className={`w-4 h-4 mr-2 transform transition-transform ${
open ? 'rotate-90' : ''
}`}
className={`w-4 h-4 mr-2 transform transition-transform ${open ? 'rotate-90' : ''
}`}
/>
<FolderIcon className="w-4 h-4 mr-2 text-[var(--vscode-symbolIcon-folderForeground)]" />
<span className="font-medium text-[var(--vscode-editor-foreground)]">
@@ -158,6 +198,9 @@ export const StructureView: React.FunctionComponent<IStructureViewProps> = ({
</Disclosure.Button>
<Disclosure.Panel className="mt-2">
{/* Child folders */}
{node.children.map(child => renderFolderNode(child, depth + 1))}
{/* Pages in this folder */}
{node.pages.length > 0 && (
<ul className="space-y-1 mb-3">
@@ -168,9 +211,6 @@ export const StructureView: React.FunctionComponent<IStructureViewProps> = ({
))}
</ul>
)}
{/* Child folders */}
{node.children.map(child => renderFolderNode(child, depth + 1))}
</Disclosure.Panel>
</>
)}

View File

@@ -1,4 +1,4 @@
import { I18nConfig } from '../../models';
import { ContentFolder, I18nConfig } from '../../models';
export interface Page {
// Properties for caching
@@ -20,15 +20,16 @@ export interface Page {
fmCategories: string[];
fmContentType: string;
fmDateFormat: string | undefined;
fmPageFolder: ContentFolder | undefined;
// i18n fields
fmDefaultLocale?: boolean;
fmLocale?: I18nConfig;
fmTranslations?: {
fmTranslations?: {
[locale: string]: {
locale: I18nConfig;
path: string;
}
};
};
title: string;

View File

@@ -219,6 +219,7 @@ export class PagesParser {
const isDefaultLanguage = await i18n.isDefaultLanguage(filePath);
const locale = await i18n.getLocale(filePath);
const translations = await i18n.getTranslations(filePath);
const pageFolder = await Folders.getPageFolderByFilePath(filePath);
const page: Page = {
...article.data,
@@ -241,6 +242,7 @@ export class PagesParser {
fmContentType: contentType.name || DEFAULT_CONTENT_TYPE_NAME,
fmBody: article?.content || '',
fmDateFormat: dateFormat,
fmPageFolder: pageFolder,
// i18n properties
fmDefaultLocale: isDefaultLanguage,
fmLocale: locale,