From 5f0fd29cca320db31d6b16d7389d23e9880231a0 Mon Sep 17 00:00:00 2001 From: Elio Struyf Date: Sun, 18 Feb 2024 16:12:01 +0100 Subject: [PATCH] #756 - i18n content creation --- assets/icons/i18n-dark.svg | 3 + assets/icons/i18n-light.svg | 3 + package.json | 24 +- package.nls.json | 2 + src/commands/Folders.ts | 171 ++++++--- src/commands/StatusListener.ts | 6 + src/commands/i18n.ts | 328 +++++++++++++++++- src/constants/context.ts | 2 + .../components/Contents/I18nLabel.tsx | 22 ++ .../components/Contents/Item.tsx | 35 +- src/dashboardWebView/models/Page.ts | 7 + src/helpers/ArticleHelper.ts | 42 ++- src/helpers/FrameworkDetector.ts | 54 +++ src/models/ContentFolder.ts | 4 + src/models/i18nConfig.ts | 4 +- src/services/PagesParser.ts | 9 + 16 files changed, 605 insertions(+), 111 deletions(-) create mode 100644 assets/icons/i18n-dark.svg create mode 100644 assets/icons/i18n-light.svg create mode 100644 src/dashboardWebView/components/Contents/I18nLabel.tsx diff --git a/assets/icons/i18n-dark.svg b/assets/icons/i18n-dark.svg new file mode 100644 index 00000000..9e723b9f --- /dev/null +++ b/assets/icons/i18n-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/i18n-light.svg b/assets/icons/i18n-light.svg new file mode 100644 index 00000000..65097821 --- /dev/null +++ b/assets/icons/i18n-light.svg @@ -0,0 +1,3 @@ + + + diff --git a/package.json b/package.json index 8a002aaf..1498910c 100644 --- a/package.json +++ b/package.json @@ -257,7 +257,7 @@ "markdownDescription": "%setting.frontMatter.content.hideFmMessage.markdownDescription%", "scope": "Content" }, - "frontMatter.content.pageFolderscontent.pageFolders": { + "frontMatter.content.pageFolders": { "type": "array", "default": [], "markdownDescription": "%setting.frontMatter.content.pageFolders.markdownDescription%", @@ -303,6 +303,10 @@ "type": "boolean", "default": false, "description": "%setting.frontMatter.content.pageFolders.items.properties.disableCreation.description%" + }, + "defaultLocale": { + "type": "string", + "description": "%setting.frontMatter.content.pageFolders.items.properties.defaultLocale.description%" } }, "additionalProperties": false, @@ -335,8 +339,7 @@ }, "additionalProperties": false, "required": [ - "locale", - "path" + "locale" ] }, "scope": "Content" @@ -2311,7 +2314,11 @@ { "command": "frontMatter.i18n.create", "title": "%command.frontMatter.i18n.create%", - "category": "Front Matter" + "category": "Front Matter", + "icon": { + "light": "assets/icons/i18n-light.svg", + "dark": "assets/icons/i18n-dark.svg" + } } ], "submenus": [{ @@ -2353,6 +2360,11 @@ "group": "navigation@-128", "when": "frontMatter:file:isValid == true" }, + { + "command": "frontMatter.i18n.create", + "group": "navigation@-127", + "when": "frontMatter:file:isValid && frontMatter:i18n:default" + }, { "command": "frontMatter.markup.options", "group": "navigation@-126", @@ -2452,6 +2464,10 @@ "command": "frontMatter.git.sync", "when": "frontMatter:git:enabled" }, + { + "command": "frontMatter.i18n.create", + "when": "frontMatter:i18n:default" + }, { "command": "frontMatter.collapseSections", "when": "false" diff --git a/package.nls.json b/package.nls.json index 3127bd29..736ca210 100644 --- a/package.nls.json +++ b/package.nls.json @@ -48,6 +48,7 @@ "command.frontMatter.markup.unorderedlist": "Unordered list", "command.frontMatter.git.sync": "Sync", "command.frontMatter.cache.clear": "Clear cache", + "command.frontMatter.i18n.create": "Create new translation", "settings.configuration.title": "Front Matter: use frontmatter.json for shared team settings", "setting.frontMatter.projects.markdownDescription": "Specify the list of projects to load in the Front Matter CMS. [Check in the docs](https://frontmatter.codes/docs/settings/overview#frontmatter.projects)", "setting.frontMatter.projects.items.properties.name.markdownDescription": "Specify the name of the project.", @@ -75,6 +76,7 @@ "setting.frontMatter.content.pageFolders.items.properties.filePrefix.description": "Defines a prefix for the file name.", "setting.frontMatter.content.pageFolders.items.properties.contentTypes.description": "Defines which content types can be used for the current location. If not defined, all content types will be available.", "setting.frontMatter.content.pageFolders.items.properties.disableCreation.description": "Disable the creation of new content in the folder.", + "setting.frontMatter.content.pageFolders.items.properties.defaultLocale.description": "Set the page folder as a default locale for the content. All content from this folder is translatable to the languages defined in the `frontMatter.content.i18n` setting.", "setting.frontMatter.content.placeholders.markdownDescription": "This array of placeholders defines the placeholders that you can use in your content types and templates for automatically populating your content its front matter. [Check in the docs](https://frontmatter.codes/docs/settings/overview#frontmatter.content.placeholders)", "setting.frontMatter.content.placeholders.items.properties.id.description": "ID of the placeholder, in your content type or template, use it as follows: {{placeholder}}", "setting.frontMatter.content.placeholders.items.properties.value.description": "The placeholder its value", diff --git a/src/commands/Folders.ts b/src/commands/Folders.ts index 7d1f4a16..935e6cb3 100644 --- a/src/commands/Folders.ts +++ b/src/commands/Folders.ts @@ -1,6 +1,7 @@ import { STATIC_FOLDER_PLACEHOLDER } from './../constants/StaticFolderPlaceholder'; import { Questions } from './../helpers/Questions'; import { + SETTING_CONTENT_I18N, SETTING_CONTENT_PAGE_FOLDERS, SETTING_CONTENT_STATIC_FOLDER, SETTING_CONTENT_SUPPORTED_FILETYPES, @@ -9,7 +10,7 @@ import { } from './../constants'; import { commands, Uri, workspace, window } from 'vscode'; import { basename, dirname, join, relative, sep } from 'path'; -import { ContentFolder, FileInfo, FolderInfo, StaticFolder } from '../models'; +import { ContentFolder, FileInfo, FolderInfo, I18nConfig, StaticFolder } from '../models'; import uniqBy = require('lodash.uniqby'); import { Template } from './Template'; import { Notifications } from '../helpers/Notifications'; @@ -288,67 +289,34 @@ export class Folders { const folderInfo: FolderInfo[] = []; for (const folder of folders) { - try { - const folderPath = parseWinPath(folder.path); + const crntFolderInfo = await Folders.getFilesByFolder(folder, supportedFiles, limit); + if (crntFolderInfo) { + folderInfo.push(crntFolderInfo); + } - if (typeof folderPath === 'string') { - let files: Uri[] = []; + // Process localization folders + if (folder.defaultLocale) { + const i18nConfig = folder.locales || Settings.get(SETTING_CONTENT_I18N); + if (i18nConfig) { + for (const i18n of i18nConfig) { + if (i18n.locale !== folder.defaultLocale && i18n.path) { + const i18nFolder = { + ...folder, + path: join(folder.path, i18n.path), + title: `${folder.title} (${i18n.title})` + } as ContentFolder; - for (const fileType of supportedFiles || DEFAULT_FILE_TYPES) { - let filePath = join( - folderPath, - folder.excludeSubdir ? '/' : '**', - `*${fileType.startsWith('.') ? '' : '.'}${fileType}` - ); - - if (folderPath === '' && folder.excludeSubdir) { - filePath = `*${fileType.startsWith('.') ? '' : '.'}${fileType}`; - } - - let foundFiles = await Folders.findFiles(filePath); - - // Make sure these file are coming from the folder path (this could be an issue in multi-root workspaces) - foundFiles = foundFiles.filter((f) => parseWinPath(f.fsPath).startsWith(folderPath)); - - files = [...files, ...foundFiles]; - } - - if (files) { - let fileStats: FileInfo[] = []; - - for (const file of files) { - try { - const fileName = basename(file.fsPath); - const folderName = dirname(file.fsPath).split(sep).pop(); - - const stats = await workspace.fs.stat(file); - - fileStats.push({ - filePath: file.fsPath, - fileName, - folderName, - ...stats - }); - } catch (error) { - // Skip the file + const crntFolderInfo = await Folders.getFilesByFolder( + i18nFolder, + supportedFiles, + limit + ); + if (crntFolderInfo) { + folderInfo.push(crntFolderInfo); } } - - fileStats = fileStats.sort((a, b) => b.mtime - a.mtime); - - if (limit) { - fileStats = fileStats.slice(0, limit); - } - - folderInfo.push({ - title: folder.title, - files: files.length, - lastModified: fileStats - }); } } - } catch (e) { - // Skip the current folder } } @@ -603,6 +571,97 @@ export class Folders { return; } + /** + * Retrieves the page folder that matches the given file path. + * + * @param filePath - The file path to match against the page folders. + * @returns The page folder that matches the file path, or undefined if no match is found. + */ + public static getPageFolderByFilePath(filePath: string): ContentFolder | undefined { + const folders = Folders.get(); + const parsedPath = parseWinPath(filePath); + const pageFolderMatches = folders + .filter((folder) => parsedPath && folder.path && parsedPath.includes(folder.path)) + .sort((a, b) => b.path.length - a.path.length); + + if (pageFolderMatches.length > 0 && pageFolderMatches[0]) { + return pageFolderMatches[0]; + } + + return; + } + + private static async getFilesByFolder( + folder: ContentFolder, + supportedFiles: string[] | undefined, + limit?: number + ): Promise { + try { + const folderPath = parseWinPath(folder.path); + + if (typeof folderPath === 'string') { + let files: Uri[] = []; + + for (const fileType of supportedFiles || DEFAULT_FILE_TYPES) { + let filePath = join( + folderPath, + folder.excludeSubdir ? '/' : '**', + `*${fileType.startsWith('.') ? '' : '.'}${fileType}` + ); + + if (folderPath === '' && folder.excludeSubdir) { + filePath = `*${fileType.startsWith('.') ? '' : '.'}${fileType}`; + } + + let foundFiles = await Folders.findFiles(filePath); + + // Make sure these file are coming from the folder path (this could be an issue in multi-root workspaces) + foundFiles = foundFiles.filter((f) => parseWinPath(f.fsPath).startsWith(folderPath)); + + files = [...files, ...foundFiles]; + } + + if (files) { + let fileStats: FileInfo[] = []; + + for (const file of files) { + try { + const fileName = basename(file.fsPath); + const folderName = dirname(file.fsPath).split(sep).pop(); + + const stats = await workspace.fs.stat(file); + + fileStats.push({ + filePath: file.fsPath, + fileName, + folderName, + ...stats + }); + } catch (error) { + // Skip the file + } + } + + fileStats = fileStats.sort((a, b) => b.mtime - a.mtime); + + if (limit) { + fileStats = fileStats.slice(0, limit); + } + + return { + title: folder.title, + files: files.length, + lastModified: fileStats + }; + } + } + } catch (e) { + // Skip the current folder + } + + return; + } + /** * Retrieve all content folders * @param pattern diff --git a/src/commands/StatusListener.ts b/src/commands/StatusListener.ts index c665eac6..50effdc5 100644 --- a/src/commands/StatusListener.ts +++ b/src/commands/StatusListener.ts @@ -19,6 +19,7 @@ import { Field } from '../models'; import { Preview } from './Preview'; import * as l10n from '@vscode/l10n'; import { LocalizationKey } from '../localization'; +import { i18n } from './i18n'; export class StatusListener { /** @@ -42,6 +43,10 @@ export class StatusListener { try { commands.executeCommand('setContext', CONTEXT.isValidFile, true); + // Check i18n + const isI18nDefault = await i18n.isDefaultLanguage(document.uri.fsPath); + commands.executeCommand('setContext', CONTEXT.isI18nDefault, isI18nDefault); + const article = editor ? ArticleHelper.getFrontMatter(editor) : await ArticleHelper.getFrontMatterByPath(document.uri.fsPath); @@ -83,6 +88,7 @@ export class StatusListener { } } else { commands.executeCommand('setContext', CONTEXT.isValidFile, false); + commands.executeCommand('setContext', CONTEXT.isI18nDefault, false); const panel = PanelProvider.getInstance(); if (panel && panel.visible) { diff --git a/src/commands/i18n.ts b/src/commands/i18n.ts index e333ab3c..4128f1b8 100644 --- a/src/commands/i18n.ts +++ b/src/commands/i18n.ts @@ -1,43 +1,233 @@ -import { commands, window } from 'vscode'; -import { ArticleHelper, Extension, Notifications, Settings } from '../helpers'; +import { Uri, commands, window, workspace } from 'vscode'; +import { + ArticleHelper, + ContentType, + Extension, + FrameworkDetector, + Notifications, + Settings, + openFileInEditor, + parseWinPath +} from '../helpers'; import { COMMAND_NAME, SETTING_CONTENT_I18N } from '../constants'; -import { I18nConfig } from '../models'; +import { ContentFolder, Field, I18nConfig, ContentType as IContentType } from '../models'; +import { join, parse } from 'path'; +import { existsAsync } from '../utils'; +import { Folders } from '.'; +import { ParsedFrontMatter } from '../parsers'; + +// TODO: +// Allow sponsors to automatically translate the content +// Support page bundles +// Filter on locale +// Locale settings on the page folder level and global level +// Show the i18n content -> if default locale is in subfolder, the other content is not found +// Update the page folder setting to include the locales property (use #ref) export class i18n { + /** + * Registers the i18n commands. + */ public static register() { const subscriptions = Extension.getInstance().subscriptions; subscriptions.push(commands.registerCommand(COMMAND_NAME.i18n.create, i18n.create)); } - public static getSettings(): I18nConfig[] | undefined { - const i18nSettings = Settings.get(SETTING_CONTENT_I18N); + /** + * Retrieves the I18nConfig settings from the application. + * @returns An array of I18nConfig objects if settings are found, otherwise undefined. + */ + public static async getSettings(filePath: string): Promise { + if (!filePath) { + return; + } + const i18nSettings = Settings.get(SETTING_CONTENT_I18N); + let pageFolder = Folders.getPageFolderByFilePath(filePath); + if (!pageFolder) { + const folders = Folders.get(); + + const localeFolders = folders?.filter((folder) => folder.defaultLocale); + if (!localeFolders) { + return; + } + + const fileName = parse(filePath).base; + for (const folder of localeFolders) { + const defaultFile = join(folder.path, fileName); + if (await existsAsync(defaultFile)) { + pageFolder = folder; + break; + } + } + } + + if (!pageFolder || !pageFolder.locales) { + return i18nSettings; + } + + return pageFolder.locales; + } + + /** + * Checks if the given file path corresponds to the default language. + * @param filePath - The file path to check. + * @returns True if the file path corresponds to the default language, false otherwise. + */ + public static async isDefaultLanguage(filePath: string): Promise { + const i18nSettings = await i18n.getSettings(filePath); + if (!i18nSettings) { + return false; + } + + const pageFolder = Folders.getPageFolderByFilePath(filePath); + if (!pageFolder || !pageFolder.defaultLocale) { + return false; + } + + const fileInfo = parse(filePath); + const dir = fileInfo.dir; + + if (pageFolder.path) { + return parseWinPath(dir).toLowerCase() === parseWinPath(pageFolder.path).toLowerCase(); + } + + return false; + } + + /** + * Retrieves the I18nConfig for a given file path. + * @param filePath - The path of the file. + * @returns The I18nConfig object if found, otherwise undefined. + */ + public static async getLocale(filePath: string): Promise { + const i18nSettings = await i18n.getSettings(filePath); if (!i18nSettings) { return; } - return i18nSettings; + const pageFolder = Folders.getPageFolderByFilePath(filePath); + const fileInfo = parse(filePath); + if (pageFolder && pageFolder.defaultLocale) { + if ( + pageFolder.path && + parseWinPath(fileInfo.dir).toLowerCase() === parseWinPath(pageFolder.path).toLowerCase() + ) { + return i18nSettings.find((i18n) => i18n.locale === pageFolder.defaultLocale); + } + } + + const folders = Folders.get(); + if (!folders) { + return; + } + + const fileName = fileInfo.base; + const defaultLanguageFolders = folders.filter((folder) => folder.defaultLocale); + for (const folder of defaultLanguageFolders) { + for (const locale of i18nSettings) { + if (locale.path && folder.defaultLocale !== locale.locale) { + const translation = join(folder.path, locale.path, fileName); + if (parseWinPath(translation).toLowerCase() === parseWinPath(filePath).toLowerCase()) { + return locale; + } + } + } + } + + return; } - private static async create(filePath?: string) { - const i18nSettings = i18n.getSettings(); + /** + * Retrieves translations for a given file path. + * @param filePath - The path of the file for which translations are requested. + * @returns A promise that resolves to an object containing translations for each locale, or undefined if i18n settings are not available. + */ + public static async getTranslations( + filePath: string + ): Promise<{ [locale: string]: string } | undefined> { + const i18nSettings = await i18n.getSettings(filePath); + if (!i18nSettings) { + return; + } + + const translations: { [locale: string]: string } = {}; + + const pageFolder = Folders.getPageFolderByFilePath(filePath); + if (pageFolder && pageFolder.defaultLocale) { + for (const i18n of i18nSettings) { + if (i18n.path) { + const translation = join(pageFolder.path, i18n.path, filePath); + if (await existsAsync(translation)) { + translations[i18n.locale] = translation; + } + } + } + return translations; + } + + const folders = Folders.get(); + if (!folders) { + return; + } + + const fileName = parse(filePath).base; + const defaultLanguageFolders = folders.filter((folder) => folder.defaultLocale); + let defaultLanguageFolder: ContentFolder | undefined; + for (const folder of defaultLanguageFolders) { + const defaultFile = join(folder.path, fileName); + if (await existsAsync(defaultFile)) { + defaultLanguageFolder = folder; + break; + } + } + + if (!defaultLanguageFolder) { + return translations; + } + + for (const i18n of i18nSettings) { + const translation = join(defaultLanguageFolder.path, i18n.path || '', fileName); + if (await existsAsync(translation)) { + translations[i18n.locale] = translation; + } + } + + return translations; + } + + /** + * Creates a new content file for a specific locale based on the i18n configuration. + * If a file path is provided, the new content file will be created in the same directory. + * If no file path is provided, the active file in the editor will be used. + * @param filePath The path of the file where the new content file should be created. + */ + private static async create(fileUri?: Uri) { + if (!fileUri) { + const filePath = ArticleHelper.getActiveFile(); + fileUri = filePath ? Uri.file(filePath) : undefined; + } + + if (!fileUri) { + Notifications.warning('No file selected'); + return; + } + + const i18nSettings = await i18n.getSettings(fileUri.fsPath); if (!i18nSettings) { Notifications.warning('No i18n configuration found'); return; } - if (!filePath) { - filePath = ArticleHelper.getActiveFile(); - } - - if (!filePath) { - Notifications.warning('No file selected'); + const isDefaultLanguage = await i18n.isDefaultLanguage(fileUri.fsPath); + if (!isDefaultLanguage) { + Notifications.warning('The current file cannot be used for i18n content creation'); return; } const locale = await window.showQuickPick( - i18nSettings.map((i18n) => i18n.title || i18n.locale), + i18nSettings.filter((i18n) => i18n.path).map((i18n) => i18n.title || i18n.locale), { title: 'Create content for locale', placeHolder: 'To which locale do you want to create a new content?', @@ -52,12 +242,114 @@ export class i18n { const selectedI18n = i18nSettings.find( (i18n) => i18n.title === locale || i18n.locale === locale ); - if (!selectedI18n) { + if (!selectedI18n || !selectedI18n.path) { Notifications.warning('No i18n configuration found'); return; } - // TODO: start from a page, or use a path - Notifications.info('Not implemented yet'); + let article = await ArticleHelper.getFrontMatterByPath(fileUri.fsPath); + if (!article) { + Notifications.warning('No content found'); + return; + } + + const contentType = ArticleHelper.getContentType(article); + if (!contentType) { + Notifications.warning('No content type found'); + return; + } + + // Get the directory of the file + const fileInfo = parse(fileUri.fsPath); + const i18nDir = join(fileInfo.dir, selectedI18n.path); + + if (!(await existsAsync(i18nDir))) { + await workspace.fs.createDirectory(Uri.file(i18nDir)); + } + + article = await i18n.updateFrontMatter( + article, + fileUri.fsPath, + contentType, + selectedI18n, + i18nDir + ); + + const newFilePath = join(i18nDir, fileInfo.base); + if (await existsAsync(newFilePath)) { + Notifications.warning('File already exists'); + return; + } + + const newFileUri = Uri.file(newFilePath); + await workspace.fs.writeFile( + newFileUri, + Buffer.from(ArticleHelper.stringifyFrontMatter(article.content, article.data)) + ); + + await openFileInEditor(newFilePath); + + Notifications.info(`Created "${selectedI18n.title || selectedI18n.locale}" i18n content file`); + } + + /** + * Updates the front matter of an article with internationalization (i18n) support. + * + * @param article - The parsed front matter of the article. + * @param filePath - The path of the file containing the front matter. + * @param contentType - The content type of the article. + * @param i18nConfig - The configuration for internationalization. + * @param i18nDir - The directory where the i18n files are located. + * @returns A Promise that resolves to the updated parsed front matter. + */ + private static async updateFrontMatter( + article: ParsedFrontMatter, + filePath: string, + contentType: IContentType, + i18nConfig: I18nConfig, + i18nDir: string + ): Promise { + const imageFields = ContentType.findFieldsByTypeDeep(contentType.fields, 'image'); + if (imageFields.length > 0) { + article.data = await i18n.processImageFields(article.data, filePath, imageFields, i18nDir); + } + + return article; + } + + /** + * Processes the image fields in the provided data object. + * Replaces the image field values with the relative path to the image file. + * + * @param data - The data object containing the field values. + * @param filePath - The absolute file path of the data object. + * @param fields - The array of field arrays to process. + * @param i18nDir - The directory path for internationalization. + * @returns The updated data object with image field values replaced by relative paths. + */ + private static async processImageFields( + data: { [key: string]: any }, + filePath: string, + fields: Field[][], + i18nDir: string + ) { + for (const field of fields) { + if (!field) { + continue; + } + + for (const f of field) { + if (f.type === 'image') { + const value = data[f.name]; + if (value) { + let imgPath = FrameworkDetector.getAbsPathByFile(value, filePath); + imgPath = FrameworkDetector.getRelPathByFileDir(imgPath, i18nDir); + data[f.name] = imgPath; + } + } + } + } + + return data; } } diff --git a/src/constants/context.ts b/src/constants/context.ts index 9b68b2aa..d169dcc7 100644 --- a/src/constants/context.ts +++ b/src/constants/context.ts @@ -8,6 +8,8 @@ export const CONTEXT = { isValidFile: 'frontMatter:file:isValid', isDevelopment: 'frontMatter:isDevelopment', + isI18nDefault: 'frontMatter:i18n:default', + hasViewModes: 'frontMatter:has:modes', isSnippetsDashboardEnabled: 'frontMatter:dashboard:snippets:enabled', diff --git a/src/dashboardWebView/components/Contents/I18nLabel.tsx b/src/dashboardWebView/components/Contents/I18nLabel.tsx new file mode 100644 index 00000000..9861f9ab --- /dev/null +++ b/src/dashboardWebView/components/Contents/I18nLabel.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { Page } from '../../models'; +import { LanguageIcon } from '@heroicons/react/24/outline'; + +export interface II18nLabelProps { + page: Page; +} + +export const I18nLabel: React.FunctionComponent = ({ + page +}: React.PropsWithChildren) => { + if (!page.fmLocale) { + return null; + } + + return ( +
+ + {page.fmLocale.title || page.fmLocale.locale} +
+ ); +}; \ No newline at end of file diff --git a/src/dashboardWebView/components/Contents/Item.tsx b/src/dashboardWebView/components/Contents/Item.tsx index 02ded451..5844e8ab 100644 --- a/src/dashboardWebView/components/Contents/Item.tsx +++ b/src/dashboardWebView/components/Contents/Item.tsx @@ -16,6 +16,7 @@ import { LocalizationKey } from '../../../localization'; import { useNavigate } from 'react-router-dom'; import { routePaths } from '../..'; import useCard from '../../hooks/useCard'; +import { I18nLabel } from './I18nLabel'; export interface IItemProps extends Page { } @@ -132,6 +133,8 @@ export const Item: React.FunctionComponent = ({ onOpen={openFile} /> + + - + { + (escapedDescription || descriptionHtml) && ( + + ) + } { tagsHtml ? ( diff --git a/src/dashboardWebView/models/Page.ts b/src/dashboardWebView/models/Page.ts index 2d20f3e0..dce8467d 100644 --- a/src/dashboardWebView/models/Page.ts +++ b/src/dashboardWebView/models/Page.ts @@ -1,3 +1,5 @@ +import { I18nConfig } from '../../models'; + export interface Page { // Properties for caching fmCachePath: string; @@ -19,6 +21,11 @@ export interface Page { fmContentType: string; fmDateFormat: string | undefined; + // i18n fields + fmDefaultLocale?: boolean; + fmLocale?: I18nConfig; + fmTranslations?: { [locale: string]: string }; + title: string; slug: string; date: string | Date; diff --git a/src/helpers/ArticleHelper.ts b/src/helpers/ArticleHelper.ts index 03c8de9d..d3aa808f 100644 --- a/src/helpers/ArticleHelper.ts +++ b/src/helpers/ArticleHelper.ts @@ -123,8 +123,14 @@ export class ArticleHelper { * Retrieve the file's front matter by its path * @param filePath */ - public static async getFrontMatterByPath(filePath: string) { - const file = await readFileAsync(filePath, { encoding: 'utf-8' }); + public static async getFrontMatterByPath( + filePath: string + ): Promise { + const file = await ArticleHelper.getContents(filePath); + if (!file) { + return undefined; + } + const article = ArticleHelper.parseFile(file, filePath); if (!article) { return undefined; @@ -136,6 +142,20 @@ export class ArticleHelper { }; } + /** + * Reads the contents of a file asynchronously. + * @param filePath - The path of the file to read. + * @returns A promise that resolves to the contents of the file, or undefined if the file does not exist. + */ + public static async getContents(filePath: string): Promise { + const file = await workspace.fs.readFile(Uri.file(parseWinPath(filePath))); + if (!file) { + return undefined; + } + + return new TextDecoder().decode(file); + } + /** * Store the new information in the file * @@ -370,21 +390,9 @@ export class ArticleHelper { if (article.data.type) { contentType = contentTypes.find((ct) => ct.name === article.data.type); } else if (!contentType && article.path) { - // Get the content type by the folder name - let folders = Folders.get(); - let parsedPath = parseWinPath(article.path); - let pageFolderMatches = folders.filter( - (folder) => parsedPath && folder.path && parsedPath.includes(folder.path) - ); - - // Sort by longest path - pageFolderMatches = pageFolderMatches.sort((a, b) => b.path.length - a.path.length); - if ( - pageFolderMatches.length > 0 && - pageFolderMatches[0].contentTypes && - pageFolderMatches[0].contentTypes.length === 1 - ) { - const contentTypeName = pageFolderMatches[0].contentTypes[0]; + const pageFolder = Folders.getPageFolderByFilePath(article.path); + if (pageFolder && pageFolder.contentTypes?.length === 1) { + const contentTypeName = pageFolder.contentTypes[0]; contentType = contentTypes.find((ct) => ct.name === contentTypeName); } } diff --git a/src/helpers/FrameworkDetector.ts b/src/helpers/FrameworkDetector.ts index 0c98e7f8..dd44466e 100644 --- a/src/helpers/FrameworkDetector.ts +++ b/src/helpers/FrameworkDetector.ts @@ -151,6 +151,60 @@ export class FrameworkDetector { return parseWinPath(relAssetPath); } + /** + * Returns the absolute path by combining the relative path and the file path. + * If a static folder is configured, it will be taken into account. + * @param relAssetPath The relative path. + * @param filePath The file path. + * @returns The absolute path. + */ + public static getAbsPathByFile(relAssetPath: string, filePath: string): string { + const staticFolderValue = Settings.get(SETTING_CONTENT_STATIC_FOLDER); + const staticFolder = Folders.getStaticFolderRelativePath(); + + if ( + staticFolderValue && + staticFolder && + typeof staticFolderValue !== 'string' && + staticFolderValue.relative + ) { + const fileDir = dirname(filePath); + return parseWinPath(join(fileDir, relAssetPath)); + } + + return relAssetPath; + } + + /** + * Returns the relative path of an asset file based on the provided absolute asset path and file path. + * If the static folder setting is configured and the static folder is available, the relative path is calculated based on the file and asset directories. + * Otherwise, the absolute asset path is returned as is. + * + * @param absAssetPath The absolute path of the asset file. + * @param fileDir The path of the directory + * @returns The relative path of the asset file. + */ + public static getRelPathByFileDir(absAssetPath: string, fileDir: string): string { + const staticFolderValue = Settings.get(SETTING_CONTENT_STATIC_FOLDER); + const staticFolder = Folders.getStaticFolderRelativePath(); + + if ( + staticFolderValue && + staticFolder && + typeof staticFolderValue !== 'string' && + staticFolderValue.relative + ) { + const assetDir = dirname(absAssetPath); + const fileName = parse(absAssetPath); + + let relAssetPath = relative(fileDir, assetDir); + relAssetPath = join(relAssetPath, `${fileName.name}${fileName.ext}`); + return parseWinPath(relAssetPath); + } + + return absAssetPath; + } + /** * Define the default settings for Hexo */ diff --git a/src/models/ContentFolder.ts b/src/models/ContentFolder.ts index 734245a8..88f00a1d 100644 --- a/src/models/ContentFolder.ts +++ b/src/models/ContentFolder.ts @@ -1,3 +1,5 @@ +import { I18nConfig } from './i18nConfig'; + export interface ContentFolder { title: string; path: string; @@ -10,4 +12,6 @@ export interface ContentFolder { originalPath?: string; $schema?: string; extended?: boolean; + defaultLocale?: string; + locales: I18nConfig[]; } diff --git a/src/models/i18nConfig.ts b/src/models/i18nConfig.ts index 033b9325..1e472c1f 100644 --- a/src/models/i18nConfig.ts +++ b/src/models/i18nConfig.ts @@ -1,5 +1,5 @@ export interface I18nConfig { - title?: string; locale: string; - path: string; + title?: string; + path?: string; } diff --git a/src/services/PagesParser.ts b/src/services/PagesParser.ts index 06e95227..f573ebff 100644 --- a/src/services/PagesParser.ts +++ b/src/services/PagesParser.ts @@ -1,3 +1,4 @@ +import { i18n } from './../commands/i18n'; import { STATIC_FOLDER_PLACEHOLDER } from './../constants/StaticFolderPlaceholder'; import { parseWinPath } from './../helpers/parseWinPath'; import { dirname, extname, join } from 'path'; @@ -209,6 +210,10 @@ export class PagesParser { escapedDescription = ''; } + const isDefaultLanguage = await i18n.isDefaultLanguage(filePath); + const locale = await i18n.getLocale(filePath); + const translations = await i18n.getTranslations(filePath); + const page: Page = { ...article.data, // Cache properties @@ -230,6 +235,10 @@ export class PagesParser { fmContentType: contentType.name || DEFAULT_CONTENT_TYPE_NAME, fmBody: article?.content || '', fmDateFormat: dateFormat, + // i18n properties + fmDefaultLocale: isDefaultLanguage, + fmLocale: locale, + fmTranslations: translations, // Make sure these are always set title: escapedTitle, description: escapedDescription,