From 6b798281ac6bc202e39ae0b9eed540124df95a93 Mon Sep 17 00:00:00 2001 From: Elio Struyf Date: Wed, 25 Nov 2020 11:15:53 +0100 Subject: [PATCH] #23 - New command to create article from template --- CHANGELOG.md | 4 ++ README.md | 10 ++++ package.json | 26 +++++++- src/commands/Article.ts | 33 +++++++---- src/commands/Template.ts | 111 +++++++++++++++++++++++++++++++++++ src/constants/settings.ts | 5 +- src/extension.ts | 12 ++++ src/helpers/ArticleHelper.ts | 22 +++++++ src/helpers/Sanitize.ts | 27 +++++++++ 9 files changed, 238 insertions(+), 12 deletions(-) create mode 100644 src/commands/Template.ts create mode 100644 src/helpers/Sanitize.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 081fa07f..42b3bfc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Change Log +## [1.9.0] - 2020-11-25 + +- [#23](https://github.com/estruyf/vscode-front-matter/issues/23): Implemented the option to create and use templates for aticle creation (front matter will be updates as well). + ## [1.8.0] - 2020-11-20 - [#22](https://github.com/estruyf/vscode-front-matter/issues/22): Allow to configure the SEO Title and Description lengths. diff --git a/README.md b/README.md index e2bd6597..3cbbc58c 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,16 @@ The extension will automatically verify if your title and description are SEO co > If you see something missing in your article creation flow, please feel free to reach out. +## Creating articles from templates + +By default, the extension looks for files stored in a `.templates` folder which should be located in the root of your website project. + +> **Info**: You can overwrite the path, by specifying it with the `frontMatter.templates.folder` setting. + +When adding files in the folder, you'll be able to runt the `Front Matter: New article from template` from a command or explorer menu. It will present you with the article template options. Once you pick one, and specify the title. It creates the file and updates its front matter. + +> **Info**: By default the extension will create articles with a `yyyy-MM-dd` prefix. If you do not want that, or change the date format, you can do this by updating the `frontMatter.templates.prefix` setting. + ## Syntax highlighting for Hugo Shortcodes ![Shortcode syntax highlighting](./assets/syntax-highlighting.png) diff --git a/package.json b/package.json index 27241124..9659bef5 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,8 @@ "onCommand:frontMatter.remap", "onCommand:frontMatter.setDate", "onCommand:frontMatter.setLastModifiedDate", - "onCommand:frontMatter.generateSlug" + "onCommand:frontMatter.generateSlug", + "onCommand:frontMatter.createFromTemplate" ], "main": "./dist/extension", "contributes": { @@ -114,6 +115,16 @@ "type": "number", "default": 160, "description": "Specifies the optimal description length for SEO (set to `-1` to turn it off)." + }, + "frontMatter.templates.folder": { + "type": "string", + "default": ".templates", + "description": "Specify the folder to use for your article templates." + }, + "frontMatter.templates.prefix": { + "type": "string", + "default": "yyyy-MM-dd", + "description": "Specify the prefix you want to add for your new article filenames." } } }, @@ -153,8 +164,21 @@ { "command": "frontMatter.generateSlug", "title": "Front Matter: Generate slug based on article title" + }, + { + "command": "frontMatter.createFromTemplate", + "title": "Front Matter: New article from template" } ], + "menus": { + "explorer/context": [ + { + "command": "frontMatter.createFromTemplate", + "when": "explorerResourceIsFolder", + "group": "Front Matter@1" + } + ] + }, "grammars": [ { "path": "./syntaxes/hugo.tmLanguage.json", diff --git a/src/commands/Article.ts b/src/commands/Article.ts index ded2e777..2415c68d 100644 --- a/src/commands/Article.ts +++ b/src/commands/Article.ts @@ -4,6 +4,7 @@ import { TaxonomyType } from "../models"; import { CONFIG_KEY, SETTING_DATE_FORMAT, EXTENSION_NAME, SETTING_SLUG_PREFIX, SETTING_SLUG_SUFFIX, SETTING_DATE_FIELD } from "../constants/settings"; import { format } from "date-fns"; import { ArticleHelper, SettingsHelper } from '../helpers'; +import matter = require('gray-matter'); export class Article { @@ -72,26 +73,19 @@ export class Article { * Sets the article date */ public static async setDate() { - const config = vscode.workspace.getConfiguration(CONFIG_KEY); const editor = vscode.window.activeTextEditor; if (!editor) { return; } - const article = ArticleHelper.getFrontMatter(editor); + let article = ArticleHelper.getFrontMatter(editor); if (!article) { return; } - const dateFormat = config.get(SETTING_DATE_FORMAT) as string; - const dateField = config.get(SETTING_DATE_FIELD) as string || "date"; - try { - if (dateFormat && typeof dateFormat === "string") { - article.data[dateField] = format(new Date(), dateFormat); - } else { - article.data[dateField] = new Date(); - } + article = this.updateDate(article, true); + try { ArticleHelper.update(editor, article); } catch (e) { vscode.window.showErrorMessage(`${EXTENSION_NAME}: Something failed while parsing the date format. Check your "${CONFIG_KEY}${SETTING_DATE_FORMAT}" setting.`); @@ -99,6 +93,25 @@ export class Article { } } + /** + * Update the date in the front matter + * @param article + */ + public static updateDate(article: matter.GrayMatterFile, forceCreate: boolean = false) { + const config = vscode.workspace.getConfiguration(CONFIG_KEY); + const dateFormat = config.get(SETTING_DATE_FORMAT) as string; + const dateField = config.get(SETTING_DATE_FIELD) as string || "date"; + + if (typeof article.data[dateField] !== "undefined" || forceCreate) { + if (dateFormat && typeof dateFormat === "string") { + article.data[dateField] = format(new Date(), dateFormat); + } else { + article.data[dateField] = new Date(); + } + } + return article; + } + /** * Sets the article lastmod date */ diff --git a/src/commands/Template.ts b/src/commands/Template.ts new file mode 100644 index 00000000..79625df2 --- /dev/null +++ b/src/commands/Template.ts @@ -0,0 +1,111 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs'; +import { CONFIG_KEY, EXTENSION_NAME, SETTING_TEMPLATES_FOLDER, SETTING_TEMPLATES_PREFIX } from '../constants'; +import { format } from 'date-fns'; +import sanitize from '../helpers/Sanitize'; +import { ArticleHelper } from '../helpers'; +import { Article } from '.'; + +export class Template { + + /** + * Create from a template + */ + public static async create(folderPath: string) { + const config = vscode.workspace.getConfiguration(CONFIG_KEY); + const folder = config.get(SETTING_TEMPLATES_FOLDER); + const prefix = config.get(SETTING_TEMPLATES_PREFIX); + + if (!folderPath) { + this.showNoTemplates(`Incorrect project folder path retrieved.`); + return; + } + + if (!folder) { + this.showNoTemplates(`No templates found.`); + return; + } + + const templates = await vscode.workspace.findFiles(`${folder}/**/*`, "**/node_modules/**,**/archetypes/**"); + if (!templates || templates.length === 0) { + this.showNoTemplates(`No templates found.`); + return; + } + + const selectedTemplate = await vscode.window.showQuickPick(templates.map(t => path.basename(t.fsPath)), { + placeHolder: `Select the article template to use` + }); + if (!selectedTemplate) { + this.showNoTemplates(`No template selected.`); + return; + } + + const titleValue = await vscode.window.showInputBox({ + prompt: `What would you like to use as a title for the new article?`, + placeHolder: `Article title` + }); + if (!titleValue) { + this.showNoTemplates(`You did not specify an article title.`); + return; + } + + // Start the template read + const template = templates.find(t => t.fsPath.endsWith(selectedTemplate)); + if (!template) { + this.showNoTemplates(`Article template could not be found.`); + return; + } + + const fileExt = path.parse(selectedTemplate).ext; + const sanitizedName = sanitize(titleValue.toLowerCase().replace(/ /g, "-")); + let newFileName = `${sanitizedName}${fileExt}`; + if (prefix && typeof prefix === "string") { + newFileName = `${format(new Date(), prefix)}-${newFileName}`; + } + + const newFilePath = path.join(folderPath, newFileName); + if (fs.existsSync(newFilePath)) { + this.showNoTemplates(`File already exists, please remove it before creating a new one with the same title.`); + return; + } + + // Start the new file creation + fs.copyFileSync(template.fsPath, newFilePath); + + // Update the properties inside the template + let frontMatter = ArticleHelper.getFrontMatterByPath(newFilePath); + if (!frontMatter) { + this.showNoTemplates(`Something failed when retrieving the newly created file.`); + return; + } + + if (frontMatter.data) { + const fmData = frontMatter.data; + if (typeof fmData.title !== "undefined") { + fmData.title = titleValue; + } + if (typeof fmData.slug !== "undefined") { + fmData.slug = sanitizedName; + } + + frontMatter = Article.updateDate(frontMatter); + + fs.writeFileSync(newFilePath, ArticleHelper.stringifyFrontMatter(frontMatter.content, frontMatter.data), { encoding: "utf8" }); + } + + const txtDoc = await vscode.workspace.openTextDocument(vscode.Uri.parse(newFilePath)); + if (txtDoc) { + vscode.window.showTextDocument(txtDoc); + } + + vscode.window.showInformationMessage(`${EXTENSION_NAME}: Your new article has been created.`); + } + + /** + * Show a warning message when no templates are found + */ + private static showNoTemplates(value: string) { + vscode.window.showWarningMessage(`${EXTENSION_NAME}: ${value}`); + } +} \ No newline at end of file diff --git a/src/constants/settings.ts b/src/constants/settings.ts index b2883508..a8958c5a 100644 --- a/src/constants/settings.ts +++ b/src/constants/settings.ts @@ -17,4 +17,7 @@ export const SETTING_REMOVE_QUOTES = "taxonomy.noPropertyValueQuotes"; export const SETTING_FRONTMATTER_TYPE = "taxonomy.frontMatterType"; export const SETTING_SEO_TITLE_LENGTH = "taxonomy.seoTitleLength"; -export const SETTING_SEO_DESCRIPTION_LENGTH = "taxonomy.seoDescriptionLength"; \ No newline at end of file +export const SETTING_SEO_DESCRIPTION_LENGTH = "taxonomy.seoDescriptionLength"; + +export const SETTING_TEMPLATES_FOLDER = "templates.folder"; +export const SETTING_TEMPLATES_PREFIX = "templates.prefix"; \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index ea841de0..8364e060 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode'; import { Article, Settings, StatusListener } from './commands'; +import { Template } from './commands/Template'; import { TaxonomyType } from './models'; let frontMatterStatusBar: vscode.StatusBarItem; @@ -45,6 +46,16 @@ export function activate({ subscriptions }: vscode.ExtensionContext) { Article.generateSlug(); }); + let createFromTemplate = vscode.commands.registerCommand('frontMatter.createFromTemplate', (e: vscode.Uri) => { + let folderPath = ""; + if (e && e.fsPath) { + folderPath = e.fsPath; + } else if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) { + folderPath = vscode.workspace.workspaceFolders[0].uri.fsPath; + } + Template.create(folderPath); + }); + const toggleDraftCommand = 'frontMatter.toggleDraft'; const toggleDraft = vscode.commands.registerCommand(toggleDraftCommand, async () => { await Article.toggleDraft(); @@ -72,6 +83,7 @@ export function activate({ subscriptions }: vscode.ExtensionContext) { subscriptions.push(setDate); subscriptions.push(setLastModifiedDate); subscriptions.push(generateSlug); + subscriptions.push(createFromTemplate); subscriptions.push(toggleDraft); } diff --git a/src/helpers/ArticleHelper.ts b/src/helpers/ArticleHelper.ts index 1b4f49f7..26c804cc 100644 --- a/src/helpers/ArticleHelper.ts +++ b/src/helpers/ArticleHelper.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode'; import * as matter from "gray-matter"; +import * as fs from "fs"; import { stopWords } from '../constants/stopwords-en'; import { charMap } from '../constants/charMap'; import { CONFIG_KEY, SETTING_INDENT_ARRAY, SETTING_REMOVE_QUOTES } from '../constants'; @@ -27,6 +28,27 @@ export class ArticleHelper { return null; } + /** + * Retrieve the file's front matter by its path + * @param filePath + */ + public static getFrontMatterByPath(filePath: string) { + const file = fs.readFileSync(filePath, { encoding: "utf-8" }); + if (file) { + const language: string = getFmLanguage(); + const langOpts = getFormatOpts(language); + let article: matter.GrayMatterFile | null = matter(file, { + ...TomlEngine, + ...langOpts + }); + + if (article && article.data) { + return article; + } + } + return null; + } + /** * Store the new information in the file * diff --git a/src/helpers/Sanitize.ts b/src/helpers/Sanitize.ts new file mode 100644 index 00000000..1d3dbc84 --- /dev/null +++ b/src/helpers/Sanitize.ts @@ -0,0 +1,27 @@ +var illegalRe = /[\/\?<>\\:\*\|"]/g; +var controlRe = /[\x00-\x1f\x80-\x9f]/g; +var reservedRe = /^\.+$/; +var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i; +var windowsTrailingRe = /[\. ]+$/; + +function sanitize(input: string, replacement: string) { + if (typeof input !== 'string') { + throw new Error('Input must be string'); + } + var sanitized = input + .replace(illegalRe, replacement) + .replace(controlRe, replacement) + .replace(reservedRe, replacement) + .replace(windowsReservedRe, replacement) + .replace(windowsTrailingRe, replacement); + return sanitized; +} + +export default function (input: string, options?: any) { + var replacement = (options && options.replacement) || ''; + var output = sanitize(input, replacement); + if (replacement === '') { + return output; + } + return sanitize(output, ''); +} \ No newline at end of file