mirror of
https://github.com/estruyf/vscode-front-matter.git
synced 2026-08-07 09:23:00 +02:00
#756 - i18n content creation
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" width="16" height="16" viewBox="0 0 24 24" stroke-width="2" stroke="#C5C5C5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m10.5 21 5.25-11.25L21 21m-9-3h7.5M3 5.621a48.474 48.474 0 0 1 6-.371m0 0c1.12 0 2.233.038 3.334.114M9 5.25V3m3.334 2.364C11.176 10.658 7.69 15.08 3 17.502m9.334-12.138c.896.061 1.785.147 2.666.257m-4.589 8.495a18.023 18.023 0 0 1-3.827-5.802" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 442 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" width="16" height="16" viewBox="0 0 24 24" stroke-width="2" stroke="#424242">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m10.5 21 5.25-11.25L21 21m-9-3h7.5M3 5.621a48.474 48.474 0 0 1 6-.371m0 0c1.12 0 2.233.038 3.334.114M9 5.25V3m3.334 2.364C11.176 10.658 7.69 15.08 3 17.502m9.334-12.138c.896.061 1.785.147 2.666.257m-4.589 8.495a18.023 18.023 0 0 1-3.827-5.802" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 442 B |
+20
-4
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
+115
-56
@@ -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<I18nConfig[]>(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<FolderInfo | undefined> {
|
||||
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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+310
-18
@@ -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<I18nConfig[]>(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<I18nConfig[] | undefined> {
|
||||
if (!filePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const i18nSettings = Settings.get<I18nConfig[]>(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<boolean> {
|
||||
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<I18nConfig | undefined> {
|
||||
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<ParsedFrontMatter> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<II18nLabelProps> = ({
|
||||
page
|
||||
}: React.PropsWithChildren<II18nLabelProps>) => {
|
||||
if (!page.fmLocale) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex items-center">
|
||||
<LanguageIcon className="mr-1 h-4 w-4 inline-block" />
|
||||
<span className="text-xs">{page.fmLocale.title || page.fmLocale.locale}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<IItemProps> = ({
|
||||
onOpen={openFile}
|
||||
/>
|
||||
|
||||
<I18nLabel page={pageData} />
|
||||
|
||||
<button
|
||||
title={escapedTitle ? l10n.t(LocalizationKey.commonOpenWithValue, escapedTitle) : l10n.t(LocalizationKey.commonOpen)}
|
||||
onClick={openFile}
|
||||
@@ -140,25 +143,29 @@ export const Item: React.FunctionComponent<IItemProps> = ({
|
||||
titleHtml ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: titleHtml }} />
|
||||
) : (
|
||||
<h2 className="mb-2 font-bold">
|
||||
{escapedTitle}
|
||||
<h2 className="font-bold">
|
||||
<span>{escapedTitle}</span>
|
||||
</h2>
|
||||
)
|
||||
}
|
||||
</button>
|
||||
|
||||
<button
|
||||
title={escapedTitle ? l10n.t(LocalizationKey.commonOpenWithValue, escapedTitle) : l10n.t(LocalizationKey.commonOpen)}
|
||||
onClick={openFile}
|
||||
className={`text-left block`}>
|
||||
{
|
||||
descriptionHtml ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: descriptionHtml }} />
|
||||
) : (
|
||||
<p className={`text-xs text-[vara(--vscode-titleBar-activeForeground)]`}>{escapedDescription}</p>
|
||||
)
|
||||
}
|
||||
</button>
|
||||
{
|
||||
(escapedDescription || descriptionHtml) && (
|
||||
<button
|
||||
title={escapedTitle ? l10n.t(LocalizationKey.commonOpenWithValue, escapedTitle) : l10n.t(LocalizationKey.commonOpen)}
|
||||
onClick={openFile}
|
||||
className={`mt-2 text-left block`}>
|
||||
{
|
||||
descriptionHtml ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: descriptionHtml }} />
|
||||
) : (
|
||||
<p className={`text-xs text-[vara(--vscode-titleBar-activeForeground)]`}>{escapedDescription}</p>
|
||||
)
|
||||
}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
tagsHtml ? (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ParsedFrontMatter | undefined> {
|
||||
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<string | undefined> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string | StaticFolder>(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<string | StaticFolder>(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
|
||||
*/
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export interface I18nConfig {
|
||||
title?: string;
|
||||
locale: string;
|
||||
path: string;
|
||||
title?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
@@ -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 = '<invalid title>';
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user