mirror of
https://github.com/estruyf/vscode-front-matter.git
synced 2026-05-15 05:45:43 +02:00
676 lines
20 KiB
TypeScript
676 lines
20 KiB
TypeScript
import { ModeListener } from './../listeners/general/ModeListener';
|
|
import { PagesListener } from './../listeners/dashboard';
|
|
import { ArticleHelper, CustomScript, Settings } from ".";
|
|
import { DefaultFieldValues, FEATURE_FLAG, SETTING_CONTENT_DRAFT_FIELD, SETTING_DATE_FORMAT, SETTING_FRAMEWORK_ID, SETTING_TAXONOMY_CONTENT_TYPES, SETTING_TAXONOMY_FIELD_GROUPS, TelemetryEvent } from "../constants";
|
|
import { ContentType as IContentType, DraftField, Field, FieldGroup, FieldType, ScriptType } from '../models';
|
|
import { Uri, commands, window, ProgressLocation, workspace } from 'vscode';
|
|
import { Folders } from "../commands/Folders";
|
|
import { Questions } from "./Questions";
|
|
import { existsSync, writeFileSync } from "fs";
|
|
import { Notifications } from "./Notifications";
|
|
import { DEFAULT_CONTENT_TYPE_NAME } from "../constants/ContentType";
|
|
import { Telemetry } from './Telemetry';
|
|
import { processKnownPlaceholders } from './PlaceholderHelper';
|
|
import { basename } from 'path';
|
|
import { ParsedFrontMatter } from '../parsers';
|
|
|
|
export class ContentType {
|
|
|
|
/**
|
|
* Retrieve the draft field
|
|
* @returns
|
|
*/
|
|
public static getDraftField() {
|
|
const draftField = Settings.get<DraftField | null | undefined>(SETTING_CONTENT_DRAFT_FIELD);
|
|
if (draftField) {
|
|
return draftField;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Retrieve the field its status
|
|
* @param data
|
|
* @returns
|
|
*/
|
|
public static getDraftStatus(data: { [field: string]: any }) {
|
|
const contentType = ArticleHelper.getContentType(data);
|
|
const draftSetting = ContentType.getDraftField();
|
|
|
|
const draftField = contentType.fields.find(f => f.type === "draft");
|
|
|
|
let fieldValue = null;
|
|
|
|
if (draftField) {
|
|
fieldValue = data[draftField.name];
|
|
} else if (draftSetting && data && data[draftSetting.name]) {
|
|
fieldValue = data[draftSetting.name];
|
|
}
|
|
|
|
if (draftSetting && fieldValue !== null) {
|
|
if (draftSetting.type === "boolean") {
|
|
return fieldValue ? "Draft" : "Published";
|
|
} else {
|
|
return fieldValue;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Create content based on content types
|
|
* @returns
|
|
*/
|
|
public static async createContent() {
|
|
const selectedContentType = await Questions.SelectContentType();
|
|
if (!selectedContentType) {
|
|
return;
|
|
}
|
|
|
|
const selectedFolder = await Questions.SelectContentFolder();
|
|
if (!selectedFolder) {
|
|
return;
|
|
}
|
|
|
|
const contentTypes = ContentType.getAll();
|
|
const folders = Folders.get();
|
|
|
|
const location = folders.find(f => f.title === selectedFolder);
|
|
if (contentTypes && location) {
|
|
const folderPath = Folders.getFolderPath(Uri.file(location.path));
|
|
const contentType = contentTypes.find(ct => ct.name === selectedContentType);
|
|
if (folderPath && contentType) {
|
|
ContentType.create(contentType, folderPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieve all content types
|
|
* @returns
|
|
*/
|
|
public static getAll() {
|
|
return Settings.get<IContentType[]>(SETTING_TAXONOMY_CONTENT_TYPES);
|
|
}
|
|
|
|
/**
|
|
* Generate a content type
|
|
*/
|
|
public static async generate() {
|
|
if (!(await ContentType.verify())) {
|
|
return;
|
|
}
|
|
|
|
Telemetry.send(TelemetryEvent.generateContentType);
|
|
|
|
const content = ArticleHelper.getCurrent();
|
|
|
|
const editor = window.activeTextEditor;
|
|
const filePath = editor?.document.uri.fsPath;
|
|
|
|
if (!content || !content.data) {
|
|
Notifications.warning(`No front matter data found to generate a content type.`);
|
|
return;
|
|
}
|
|
|
|
const override = await window.showQuickPick(["Yes", "No"], {
|
|
title: "Override default content type",
|
|
placeHolder: "Do you want to override the default content type?",
|
|
ignoreFocusOut: true
|
|
});
|
|
const overrideBool = override === "Yes";
|
|
|
|
let contentTypeName: string | undefined = `default`;
|
|
|
|
// Ask for the new content type name
|
|
if (!overrideBool) {
|
|
contentTypeName = await window.showInputBox({
|
|
title: "Generate Content Type",
|
|
placeHolder: "Enter the name of the content type to generate",
|
|
prompt: "Enter the name of the content type to generate",
|
|
ignoreFocusOut: true,
|
|
validateInput: (value: string) => {
|
|
if (!value) {
|
|
return "Please enter a name for the content type";
|
|
}
|
|
|
|
const contentTypes = ContentType.getAll();
|
|
if (contentTypes && contentTypes.find(ct => ct.name.toLowerCase() === value.toLowerCase())) {
|
|
return "A content type with this name already exists";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
});
|
|
|
|
if (!contentTypeName) {
|
|
Notifications.warning(`You didn't specify a name for the content type.`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Ask if the content type needs to be used as a page bundle
|
|
let pageBundle = false;
|
|
const fileName = filePath ? basename(filePath) : undefined;
|
|
if (fileName?.startsWith(`index.`)) {
|
|
const pageBundleAnswer = await window.showQuickPick(["Yes", "No"], {
|
|
title: "Use as page bundle",
|
|
placeHolder: "Do you want to use this content type as a page bundle?",
|
|
ignoreFocusOut: true
|
|
});
|
|
pageBundle = pageBundleAnswer === "Yes";
|
|
}
|
|
|
|
const fields = ContentType.generateFields(content.data);
|
|
if (!overrideBool && !fields.some(f => f.name === "type")) {
|
|
fields.push({
|
|
name: "type",
|
|
type: "string",
|
|
default: contentTypeName,
|
|
hidden: true
|
|
} as Field);
|
|
}
|
|
|
|
// Update the type field in the page
|
|
if (!overrideBool && editor) {
|
|
content.data["type"] = contentTypeName;
|
|
ArticleHelper.update(editor, content);
|
|
}
|
|
|
|
const newContentType: IContentType = {
|
|
name: contentTypeName,
|
|
pageBundle,
|
|
fields
|
|
};
|
|
|
|
const contentTypes = ContentType.getAll() || [];
|
|
|
|
if (overrideBool) {
|
|
const index = contentTypes.findIndex(ct => ct.name === contentTypeName);
|
|
contentTypes[index].fields = fields;
|
|
} else {
|
|
contentTypes.push(newContentType);
|
|
}
|
|
|
|
Settings.update(SETTING_TAXONOMY_CONTENT_TYPES, contentTypes, true);
|
|
|
|
const configPath = Settings.projectConfigPath;
|
|
const notificationAction = await Notifications.info(`Content type ${contentTypeName} has been ${overrideBool ? `updated` : `generated`}.`, configPath && existsSync(configPath) ? `Open settings` : undefined);
|
|
|
|
if (notificationAction === "Open settings" && configPath && existsSync(configPath)) {
|
|
commands.executeCommand('vscode.open', Uri.file(configPath));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add missing fields to the content type
|
|
*/
|
|
public static async addMissingFields() {
|
|
if (!(await ContentType.verify())) {
|
|
return;
|
|
}
|
|
|
|
Telemetry.send(TelemetryEvent.addMissingFields);
|
|
|
|
const content = ArticleHelper.getCurrent();
|
|
|
|
if (!content || !content.data) {
|
|
Notifications.warning(`No front matter data found to add missing fields.`);
|
|
return;
|
|
}
|
|
|
|
const contentType = ArticleHelper.getContentType(content?.data);
|
|
const updatedFields = ContentType.generateFields(content.data, contentType.fields);
|
|
|
|
const contentTypes = ContentType.getAll() || [];
|
|
const index = contentTypes.findIndex(ct => ct.name === contentType.name);
|
|
contentTypes[index].fields = updatedFields;
|
|
|
|
Settings.update(SETTING_TAXONOMY_CONTENT_TYPES, contentTypes, true);
|
|
|
|
const configPath = Settings.projectConfigPath;
|
|
const notificationAction = await Notifications.info(`Content type ${contentType.name} has been updated.`, configPath && existsSync(configPath) ? `Open settings` : undefined);
|
|
|
|
if (notificationAction === "Open settings" && configPath && existsSync(configPath)) {
|
|
commands.executeCommand('vscode.open', Uri.file(configPath));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set the content type to be used for the current file
|
|
*/
|
|
public static async setContentType() {
|
|
if (!(await ContentType.verify())) {
|
|
return;
|
|
}
|
|
|
|
Telemetry.send(TelemetryEvent.setContentType);
|
|
|
|
const content = ArticleHelper.getCurrent();
|
|
const contentTypes = ContentType.getAll() || [];
|
|
|
|
if (!content || !content.data) {
|
|
Notifications.warning(`No front matter data found to set the content type.`);
|
|
return;
|
|
}
|
|
|
|
const ctAnswer = await window.showQuickPick(contentTypes.map(ct => ct.name), {
|
|
title: "Select the content type",
|
|
ignoreFocusOut: true,
|
|
placeHolder: "Which content type would you like to use?"
|
|
});
|
|
|
|
if (!ctAnswer) {
|
|
return;
|
|
}
|
|
|
|
content.data.type = ctAnswer;
|
|
|
|
const editor = window.activeTextEditor;
|
|
ArticleHelper.update(editor!, content);
|
|
}
|
|
|
|
/**
|
|
* Retrieve the field value
|
|
* @param data
|
|
* @param parents
|
|
* @returns
|
|
*/
|
|
public static getFieldValue(data: any, parents: string[]): string | string[] {
|
|
let fieldValue = [];
|
|
let crntPageData = data;
|
|
|
|
for (let i = 0; i < parents.length; i++) {
|
|
const crntField = parents[i];
|
|
|
|
if (i === parents.length - 1) {
|
|
fieldValue = crntPageData[crntField];
|
|
} else {
|
|
if (!crntPageData[crntField]) {
|
|
continue;
|
|
}
|
|
|
|
crntPageData = crntPageData[crntField];
|
|
}
|
|
}
|
|
|
|
return fieldValue;
|
|
}
|
|
|
|
/**
|
|
* Set the field value
|
|
* @param data
|
|
* @param parents
|
|
* @returns
|
|
*/
|
|
public static setFieldValue(data: any, parents: string[], value: any) {
|
|
let crntPageData = data;
|
|
|
|
for (let i = 0; i < parents.length; i++) {
|
|
const crntField = parents[i];
|
|
|
|
if (i === parents.length - 1) {
|
|
crntPageData[crntField] = value;
|
|
} else {
|
|
if (!crntPageData[crntField]) {
|
|
continue;
|
|
}
|
|
|
|
crntPageData = crntPageData[crntField];
|
|
}
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Find the field by its type
|
|
* @param fields
|
|
* @param type
|
|
* @param parents
|
|
* @returns
|
|
*/
|
|
public static findFieldByType(fields: Field[], type: FieldType, parents: string[] = []): string[] {
|
|
for (const field of fields) {
|
|
if (field.type === type) {
|
|
parents = [...parents, field.name];
|
|
return parents;
|
|
} else if (field.type === "fields" && field.fields) {
|
|
const subFields = this.findFieldByType(field.fields, type, parents);
|
|
if (subFields.length > 0) {
|
|
return [...parents, field.name, ...subFields];
|
|
}
|
|
}
|
|
}
|
|
|
|
return parents;
|
|
}
|
|
|
|
/**
|
|
* Find the preview field in the fields
|
|
* @param ctFields
|
|
* @param parents
|
|
* @returns
|
|
*/
|
|
public static findPreviewField(ctFields: Field[], parents: string[] = []): string[] {
|
|
for (const field of ctFields) {
|
|
if (field.isPreviewImage && field.type === "image") {
|
|
parents = [...parents, field.name];
|
|
return parents;
|
|
} else if (field.type === "fields" && field.fields) {
|
|
const subFields = this.findPreviewField(field.fields);
|
|
if (subFields.length > 0) {
|
|
return [...parents, field.name, ...subFields];
|
|
}
|
|
} else if (field.type === "block") {
|
|
const subFields = this.findPreviewInBlockField(field);
|
|
if (subFields.length > 0) {
|
|
return [...parents, field.name, ...subFields];
|
|
}
|
|
}
|
|
}
|
|
|
|
return parents;
|
|
}
|
|
|
|
|
|
/**
|
|
* Find the required fields
|
|
*/
|
|
public static findEmptyRequiredFields(article: ParsedFrontMatter): Field[][] | undefined {
|
|
const contentType = ArticleHelper.getContentType(article.data);
|
|
if (!contentType) {
|
|
return;
|
|
}
|
|
|
|
const allRequiredFields = ContentType.findRequiredFieldsDeep(contentType.fields);
|
|
|
|
let emptyFields: Field[][] = [];
|
|
|
|
for (const fields of allRequiredFields) {
|
|
const fieldValue = this.getFieldValue(article.data, fields.map(f => f.name));
|
|
if ((fieldValue === null || fieldValue === undefined || fieldValue === "") || fieldValue.length === 0 || fieldValue === DefaultFieldValues.faultyCustomPlaceholder) {
|
|
emptyFields.push(fields);
|
|
}
|
|
}
|
|
|
|
return emptyFields || [];
|
|
}
|
|
|
|
/**
|
|
* Find all the required fields in the content type
|
|
* @param fields
|
|
* @param parents
|
|
* @returns
|
|
*/
|
|
private static findRequiredFieldsDeep(fields: Field[], parents: Field[][] = [], parentFields: Field[] = []): Field[][] {
|
|
for (const field of fields) {
|
|
if (field.required) {
|
|
parents.push([...parentFields, field]);
|
|
}
|
|
|
|
if (field.type === "fields" && field.fields) {
|
|
this.findRequiredFieldsDeep(field.fields, parents, [...parentFields, field]);
|
|
}
|
|
}
|
|
|
|
return parents;
|
|
}
|
|
|
|
|
|
/**
|
|
* Look for the preview image in the block field
|
|
* @param field
|
|
* @param parents
|
|
* @returns
|
|
*/
|
|
private static findPreviewInBlockField(field: Field) {
|
|
const groups = field.fieldGroup && Array.isArray(field.fieldGroup) ? field.fieldGroup : [field.fieldGroup];
|
|
if (!groups) {
|
|
return [];
|
|
}
|
|
|
|
const blocks = Settings.get<FieldGroup[]>(SETTING_TAXONOMY_FIELD_GROUPS);
|
|
if (!blocks) {
|
|
return [];
|
|
}
|
|
|
|
let found = false;
|
|
for (const group of groups) {
|
|
const block = blocks.find(block => block.id === group);
|
|
if (!block) {
|
|
continue;
|
|
}
|
|
|
|
let newParents: string[] = [];
|
|
if (!found) {
|
|
newParents = this.findPreviewField(block?.fields, []);
|
|
}
|
|
|
|
if (newParents.length > 0) {
|
|
found = true;
|
|
return newParents;
|
|
}
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Generate the fields from the data
|
|
* @param data
|
|
* @param fields
|
|
* @returns
|
|
*/
|
|
private static generateFields(data: any, fields: any[] = []) {
|
|
for (const field in data) {
|
|
const fieldData = data[field];
|
|
|
|
if (fields.some(f => f.name === field)) {
|
|
continue;
|
|
}
|
|
|
|
if (fieldData && fieldData instanceof Array && fieldData.length > 0 && typeof fieldData[0] === "string") {
|
|
if (field.toLowerCase() === "tag" || field.toLowerCase() === "tags") {
|
|
fields.push({
|
|
title: field,
|
|
name: field,
|
|
type: "tags",
|
|
} as Field);
|
|
} else if (field.toLowerCase() === "category" || field.toLowerCase() === "categories") {
|
|
fields.push({
|
|
title: field,
|
|
name: field,
|
|
type: "categories",
|
|
} as Field);
|
|
} else {
|
|
fields.push({
|
|
title: field,
|
|
name: field,
|
|
type: "choice",
|
|
choices: fieldData
|
|
} as Field);
|
|
}
|
|
} else if (fieldData && fieldData instanceof Array && fieldData.length > 0 && typeof fieldData[0] === "object") {
|
|
const newFields = ContentType.generateFields(fieldData);
|
|
fields.push({
|
|
title: field,
|
|
name: field,
|
|
type: "block",
|
|
fields: newFields
|
|
} as Field);
|
|
} else if (fieldData && fieldData instanceof Object) {
|
|
const newFields = ContentType.generateFields(fieldData);
|
|
fields.push({
|
|
title: field,
|
|
name: field,
|
|
type: "fields",
|
|
fields: newFields
|
|
} as Field);
|
|
} else {
|
|
if (!isNaN(new Date(fieldData).getDate())) {
|
|
fields.push({
|
|
title: field,
|
|
name: field,
|
|
type: "datetime"
|
|
} as Field);
|
|
} else if (field.toLowerCase() === "draft") {
|
|
fields.push({
|
|
title: field,
|
|
name: field,
|
|
type: "draft"
|
|
} as Field);
|
|
} else if (field.toLowerCase() === "slug") {
|
|
// Do nothing
|
|
} else {
|
|
fields.push({
|
|
title: field,
|
|
name: field,
|
|
type: typeof fieldData
|
|
} as Field);
|
|
}
|
|
}
|
|
}
|
|
|
|
return fields;
|
|
}
|
|
|
|
/**
|
|
* Create a new file with the specified content type
|
|
* @param contentType
|
|
* @param folderPath
|
|
* @returns
|
|
*/
|
|
private static async create(contentType: IContentType, folderPath: string) {
|
|
window.withProgress({
|
|
location: ProgressLocation.Notification,
|
|
title: "Front Matter: Creating content...",
|
|
cancellable: false
|
|
}, async () => {
|
|
const titleValue = await Questions.ContentTitle();
|
|
if (!titleValue) {
|
|
return;
|
|
}
|
|
|
|
let templatePath = contentType.template;
|
|
let templateData: ParsedFrontMatter | null = null;
|
|
if (templatePath) {
|
|
templatePath = Folders.getAbsFilePath(templatePath);
|
|
templateData = ArticleHelper.getFrontMatterByPath(templatePath);
|
|
}
|
|
|
|
let newFilePath: string | undefined = ArticleHelper.createContent(contentType, folderPath, titleValue);
|
|
if (!newFilePath) {
|
|
return;
|
|
}
|
|
|
|
if (contentType.name === "default") {
|
|
const crntFramework = Settings.get<string>(SETTING_FRAMEWORK_ID);
|
|
if (crntFramework?.toLowerCase() === "jekyll") {
|
|
const idx = contentType.fields.findIndex(f => f.name === "draft");
|
|
if (idx > -1) {
|
|
contentType.fields.splice(idx, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
let data: any = await this.processFields(contentType, titleValue, templateData?.data || {}, newFilePath);
|
|
|
|
data = ArticleHelper.updateDates(Object.assign({}, data));
|
|
|
|
if (contentType.name !== DEFAULT_CONTENT_TYPE_NAME) {
|
|
data['type'] = contentType.name;
|
|
}
|
|
|
|
const content = ArticleHelper.stringifyFrontMatter(templateData?.content || ``, data);
|
|
|
|
writeFileSync(newFilePath, content, { encoding: "utf8" });
|
|
|
|
// Check if the content type has a post script to execute
|
|
if (contentType.postScript) {
|
|
const scripts = await CustomScript.getScripts();
|
|
const script = scripts.find(s => s.id === contentType.postScript);
|
|
|
|
if (script && (script.type === ScriptType.Content || !script?.type)) {
|
|
await CustomScript.run(script, newFilePath);
|
|
|
|
const doc = await workspace.openTextDocument(Uri.file(newFilePath));
|
|
await doc.save();
|
|
}
|
|
}
|
|
|
|
await commands.executeCommand('vscode.open', Uri.file(newFilePath));
|
|
|
|
Notifications.info(`Your new content has been created.`);
|
|
|
|
Telemetry.send(TelemetryEvent.createContentFromContentType);
|
|
|
|
// Trigger a refresh for the dashboard
|
|
PagesListener.refresh();
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Process all content type fields
|
|
* @param contentType
|
|
* @param data
|
|
*/
|
|
private static async processFields(obj: IContentType | Field, titleValue: string, data: any, filePath: string, isRoot: boolean = true): Promise<any> {
|
|
|
|
if (obj.fields) {
|
|
const dateFormat = Settings.get(SETTING_DATE_FORMAT) as string;
|
|
|
|
for (const field of obj.fields) {
|
|
if (field.name === "title") {
|
|
if (field.default) {
|
|
data[field.name] = processKnownPlaceholders(field.default, titleValue, dateFormat);
|
|
data[field.name] = await ArticleHelper.processCustomPlaceholders(data[field.name], titleValue, filePath);
|
|
} else if (isRoot) {
|
|
data[field.name] = titleValue;
|
|
} else {
|
|
data[field.name] = ""
|
|
}
|
|
} else {
|
|
if (field.type === "fields") {
|
|
data[field.name] = await this.processFields(field, titleValue, {}, filePath, false);
|
|
} else {
|
|
const defaultValue = field.default;
|
|
|
|
if (typeof defaultValue === "string") {
|
|
data[field.name] = processKnownPlaceholders(defaultValue, titleValue, dateFormat);
|
|
data[field.name] = await ArticleHelper.processCustomPlaceholders(data[field.name], titleValue, filePath);
|
|
} else if (typeof defaultValue !== "undefined") {
|
|
data[field.name] = defaultValue;
|
|
} else {
|
|
const draftField = ContentType.getDraftField();
|
|
|
|
if (field.type === "draft" && (draftField?.type === "boolean" || draftField?.type === undefined)) {
|
|
data[field.name] = true;
|
|
} else {
|
|
data[field.name] = "";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Verify if the content type feature is enabled
|
|
* @returns
|
|
*/
|
|
private static async verify() {
|
|
const hasFeature = await ModeListener.hasFeature(FEATURE_FLAG.panel.contentType);
|
|
if (!hasFeature) {
|
|
Notifications.warning(`The content type actions are not available in this mode.`);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
} |