diff --git a/package-lock.json b/package-lock.json index b43242a..d6fb452 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,12 +15,15 @@ "@astrojs/sitemap": "^0.1.0", "@astrojs/tailwind": "^0.2.1", "@mertasan/tailwindcss-variables": "^2.5.0", + "@types/github-slugger": "^1.3.0", "@types/node": "^18.7.14", "@types/react": "^18.0.18", "astro": "^1.0.0-beta.28", "astro-compress": "1.0.7", "autoprefixer": "^10.4.8", + "fast-glob": "^3.2.12", "fp-ts": "^2.12.3", + "github-slugger": "^1.4.0", "io-ts": "^2.2.18", "postcss": "^8.4.16", "react": "^18.1.0", @@ -996,6 +999,12 @@ "@types/estree": "*" } }, + "node_modules/@types/github-slugger": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@types/github-slugger/-/github-slugger-1.3.0.tgz", + "integrity": "sha512-J/rMZa7RqiH/rT29TEVZO4nBoDP9XJOjnbbIofg7GQKs4JIduEO3WLpte+6WeUz/TcrXKlY+bM7FYrp8yFB+3g==", + "dev": true + }, "node_modules/@types/hast": { "version": "2.3.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.4.tgz", @@ -1373,6 +1382,22 @@ "terser": "5.15.0" } }, + "node_modules/astro-compress/node_modules/fast-glob": { + "version": "3.2.11", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", + "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, "node_modules/astro/node_modules/semver": { "version": "7.3.7", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", @@ -2857,9 +2882,9 @@ } }, "node_modules/fast-glob": { - "version": "3.2.11", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", - "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==", + "version": "3.2.12", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", + "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==", "dev": true, "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -8620,6 +8645,12 @@ "@types/estree": "*" } }, + "@types/github-slugger": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@types/github-slugger/-/github-slugger-1.3.0.tgz", + "integrity": "sha512-J/rMZa7RqiH/rT29TEVZO4nBoDP9XJOjnbbIofg7GQKs4JIduEO3WLpte+6WeUz/TcrXKlY+bM7FYrp8yFB+3g==", + "dev": true + }, "@types/hast": { "version": "2.3.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.4.tgz", @@ -8961,6 +8992,21 @@ "sharp": "0.31.0", "svgo": "2.8.0", "terser": "5.15.0" + }, + "dependencies": { + "fast-glob": { + "version": "3.2.11", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", + "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + } + } } }, "autoprefixer": { @@ -9911,9 +9957,9 @@ } }, "fast-glob": { - "version": "3.2.11", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", - "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==", + "version": "3.2.12", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", + "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==", "dev": true, "requires": { "@nodelib/fs.stat": "^2.0.2", diff --git a/package.json b/package.json index ea7e8ee..67a5af1 100644 --- a/package.json +++ b/package.json @@ -17,12 +17,15 @@ "@astrojs/sitemap": "^0.1.0", "@astrojs/tailwind": "^0.2.1", "@mertasan/tailwindcss-variables": "^2.5.0", + "@types/github-slugger": "^1.3.0", "@types/node": "^18.7.14", "@types/react": "^18.0.18", "astro": "^1.0.0-beta.28", "astro-compress": "1.0.7", "autoprefixer": "^10.4.8", + "fast-glob": "^3.2.12", "fp-ts": "^2.12.3", + "github-slugger": "^1.4.0", "io-ts": "^2.2.18", "postcss": "^8.4.16", "react": "^18.1.0", diff --git a/public/assets/images/linters-formatters.png b/public/assets/images/linters-formatters.png new file mode 100644 index 0000000..8c727c3 Binary files /dev/null and b/public/assets/images/linters-formatters.png differ diff --git a/scripts/import-from-bear/config.ts b/scripts/import-from-bear/config.ts new file mode 100644 index 0000000..037fce1 --- /dev/null +++ b/scripts/import-from-bear/config.ts @@ -0,0 +1,23 @@ +type Config = { + blogTagPattern: string; + imageSearchPaths: string[]; + defaultLayout: string; + postsPath: string; + assetsUrl: string; +} + +const config: Config = { + blogTagPattern: '^(blog\/solomonhawk\/?)|(blog\/?)', + // XXX: this isn't great - is there a better way to find the images that were + // embedded in a bear post? + imageSearchPaths: [ + "~/Downloads", + "~/Documents", + "~/Pictures", + ], + defaultLayout: '@layouts/BlogPost.astro', + postsPath: 'src/pages/writing/posts', + assetsUrl: '/assets/images' +} + +export default config; diff --git a/scripts/import-from-bear/index.ts b/scripts/import-from-bear/index.ts index 7cc1258..b3148c8 100644 --- a/scripts/import-from-bear/index.ts +++ b/scripts/import-from-bear/index.ts @@ -1,15 +1,20 @@ import { pipe } from 'fp-ts/lib/function'; import * as ra from 'fp-ts/ReadonlyArray'; -import { readJSONFromStdIn, convertToMarkdown, parsePost, writePostAsMarkdown } from './lib'; +import { readJSONFromStdIn, parsePost, convertToMarkdown, extractImageFilenames, writePostAsMarkdown, copyFilesToAssets } from './lib'; async function main() { + const imageFilenames = new Set(); + pipe( await readJSONFromStdIn(), ra.filterMap(parsePost), ra.map(convertToMarkdown), - ra.map(writePostAsMarkdown) + ra.map(extractImageFilenames(imageFilenames)), + ra.map(writePostAsMarkdown), ); + await copyFilesToAssets(imageFilenames); + console.log('> Finished processing JSON.'); } diff --git a/scripts/import-from-bear/lib.ts b/scripts/import-from-bear/lib.ts index 6978dd2..c662a9d 100644 --- a/scripts/import-from-bear/lib.ts +++ b/scripts/import-from-bear/lib.ts @@ -2,8 +2,14 @@ import { isRight } from 'fp-ts/lib/Either'; import { pipe } from 'fp-ts/lib/function'; import * as O from 'fp-ts/Option'; import fs from 'fs'; +import fg from 'fast-glob'; +import os from 'os'; import path from 'path'; +import GithubSlugger from 'github-slugger'; import { PostData, PostSchema } from '../../src/types/post'; +import config from './config'; + +const slugger = new GithubSlugger(); type DBPost = { title: string; @@ -12,6 +18,15 @@ type DBPost = { tags: string; } +type FileInfoMap = { + [key: string]: { + filename: string; + info: ReturnType; + found: boolean; + path: string | null; + } +} + export async function readJSONFromStdIn(): Promise { const data = await fs.promises.readFile('/dev/stdin', 'utf-8'); return JSON.parse(data); @@ -44,29 +59,32 @@ function postDate(date: number): string { return d.toISOString(); } +/** + * Filters out tags that are specific to Bear which are prefixed with a hashtag. + * Removes "published" from the remaining tags. + * + * @param {string[]} tags a list of tags to filter + * @returns {string[]} the filtered tags + */ function filterBearTags(tags: string[]): string[] { - return tags.map(tag => tag.replace(/^(blog\/solomonhawk\/?)|(blog\/?)/, '')).filter(Boolean).filter(tag => tag !== 'published'); + return tags.map(tag => tag.replace(new RegExp(config.blogTagPattern), '')).filter(Boolean).filter(tag => tag !== 'published'); } export function convertToMarkdown(post: PostData): { filename: string, markdown: string } { console.log(`> Converting "${post.title}" to MDX`); return { - filename: slugify(post.title), + filename: slugger.slug(post.title), markdown: `--- -layout: '@layouts/BlogPost.astro' +layout: '${config.defaultLayout}' title: ${post.title} publishDate: ${post.publishDate} tags: [${post.tags?.join(', ')}] --- -${pipe(post.markdown?.trim() || '', stripNoteTitle, stripBearTags).trim()} +${pipe(post.markdown?.trim() || '', stripNoteTitle, stripBearTags, rewriteImageRefs).trim()} `}; } -function slugify(str: string): string { - return str.toLowerCase().replace(/ /g, '-'); -} - /** * Removes the first line of the note (H1 title). * @@ -85,14 +103,97 @@ function stripNoteTitle(markdown: string): string { * @returns string with Bear tags removed */ function stripBearTags(markdown: string): string { - return markdown.replace(/(?) { + return (post: { filename: string, markdown: string }) => { + // matches the URL in a markdown image tag like `![alt text]()` + const pattern = /!\[.*\]\((?[\w\/-]+\.\w+)\)/gm; + let result + + while((result = pattern.exec(post.markdown)) !== null) { + if (!result?.groups?.filename) { + console.error(`[!] Failed to extract image filename for "${post.filename}"`); + continue; + } + + imageFilenames.add(path.join(process.cwd(), 'public', result.groups.filename)); + } + + return post; + } +} + +/** + * Converts Bear's image references with markdown to render a local image file + * from the Astro assets. + * + * @param {string} markdown Markdown string with possible embedded image refs + * @returns Markdown with image refs transformed to image tags + */ +function rewriteImageRefs(markdown: string): string { + return markdown.replace(/\[image:.*\/([\w-]+)\.(\w+)\]$/gm, `![$1](${config.assetsUrl}/$1.$2)`); } export function writePostAsMarkdown({filename, markdown}: { filename: string, markdown: string }): void { - const relPath = `./src/pages/writing/posts/${filename}.mdx`; + const relPath = path.join('./', config.postsPath, `${filename}.mdx`); const fullPath = path.resolve(__dirname, '../../', relPath); console.log(`> Writing ${relPath}`); fs.writeFileSync(fullPath, markdown); } + +export async function copyFilesToAssets(filenames: Set): Promise { + const fileInfos: FileInfoMap = collectFileInfos(filenames); + + for (const searchDir of config.imageSearchPaths) { + for await (const file of streamMatches(searchDir, fileInfos)) { + updateFileInfos(fileInfos, file); + } + } + + for (const entry in fileInfos) { + if (!fileInfos[entry].found || !fileInfos[entry].path) { + console.error(`[!] Failed to find "${entry}" in search paths`); + continue; + } + + try { + await fs.promises.copyFile(fileInfos[entry].path!, fileInfos[entry].filename) + console.log(`> Copied "${entry}" to "${config.assetsUrl}"`); + } catch (err) { + console.error(`[!] Failed to copy "${entry}" to assets`); + } + } +} + +function collectFileInfos(filenames: Set): FileInfoMap { + return Object.fromEntries(Array.from(filenames).map(filename => { + const info = path.parse(filename); + return [info.base, { filename, info, found: false, path: null }] + })); +} + +function streamMatches(dir: string, fileInfos: FileInfoMap): NodeJS.ReadableStream { + // "$SEARCHDIR/**/*@(file1|file2|file3)", glob match for exactly the files we want + const pattern = path.join(dir, "**", `@(${Object.keys(fileInfos).join('|')})`); + return fg.stream(resolveDir(pattern), { followSymbolicLinks: false, suppressErrors: true }) +} + +function updateFileInfos(fileInfos: FileInfoMap, file: string | Buffer): void { + if (typeof file !== 'string') { + console.warn(`[!] Unexpected file type Buffer: ${file}`); + return; + } + + const info = path.parse(file); + + fileInfos[info.base].found = true; + fileInfos[info.base].path = file; +} + +function resolveDir(dir: string): string { + return dir.replace("~", os.homedir()); +} diff --git a/src/pages/writing/posts/is-code-formatting-a-linter-concern.mdx b/src/pages/writing/posts/is-code-formatting-a-linter-concern.mdx new file mode 100644 index 0000000..b659700 --- /dev/null +++ b/src/pages/writing/posts/is-code-formatting-a-linter-concern.mdx @@ -0,0 +1,17 @@ +--- +layout: '@layouts/BlogPost.astro' +title: Is code formatting a linter concern? +publishDate: 2022-09-26T04:00:00.000Z +tags: [draft, linting, code-quality, formatting, prettier, eslint] +--- +Linters and code-formatters are both crucial tools I rely on for writing quality software. I’m always happy to delegate effort to a tool that can statically analyze my work and provide helpful guidance. + +According to the [wiki](https://en.wikipedia.org/wiki/Lint_(software)), linting encompasses automated checks for programming errors, bugs, stylistic errors and “suspicious constructs”. + +Code-formatters are constrained to consider style and presentation only. The [wiki](https://en.wikipedia.org/wiki/Prettyprint#Programming_code_formatting) entry under `Prettyprint` describes converting source code from one format to another. + +A fair question to ask is how best to integrate tools of these 2 categories. It seems that, to a degree, the category of tools that lint code encompass those that format it. + +![linters-formatters](/assets/images/linters-formatters.png) + +In the JavaScript ecosystem, the standard toolset includes [ESLint](https://eslint.org/) and [Prettier](https://prettier.io/). One notable difference between the two is configurability and extensibility. Prettier is opinionated and has limited customization through its configuration file which is a feature, not a bug.