init
This commit is contained in:
commit
e8a255ce1f
10 changed files with 1390 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
node_modules
|
||||
test.js
|
||||
|
||||
generate/*.html
|
||||
generate/*.png
|
||||
generate/*.json
|
30
README.md
Normal file
30
README.md
Normal file
|
@ -0,0 +1,30 @@
|
|||
# custom emoji plugin for rehype
|
||||
|
||||
yeah i'm not writing an actual readme for this yet lmao
|
||||
|
||||
## usage
|
||||
|
||||
```js
|
||||
import { unified } from "unified";
|
||||
import remarkParse from "remark-parse";
|
||||
import rehypeStringify from "rehype-stringify";
|
||||
import remarkRehype from "remark-rehype";
|
||||
import rehypeCustomEmoji from "rehype-custom-emoji-plugin";
|
||||
|
||||
import jsonfileData from "./emojis.json" with { type: "json" };
|
||||
|
||||
const processor = unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkRehype)
|
||||
.use(rehypeCustomEmoji, {
|
||||
emojis: jsonfileData.emojis,
|
||||
sourceFile: jsonfileData.source,
|
||||
hidpiSourceFile: jsonfileData.hidpiSource,
|
||||
size: jsonfileData.size,
|
||||
})
|
||||
.use(rehypeStringify);
|
||||
|
||||
const file = await processor.process(":neofox:");
|
||||
```
|
||||
|
||||
generate a spritesheet file with `generate/generate.js`
|
10
dist/index.d.ts
vendored
Normal file
10
dist/index.d.ts
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
import type { Plugin } from "unified";
|
||||
import type { Root } from "hast";
|
||||
interface RehypeCustomEmojiOptions {
|
||||
sourceFile: string;
|
||||
hidpiSourceFile?: string;
|
||||
emojis: Record<string, [number, number]>;
|
||||
size: number;
|
||||
}
|
||||
declare const plugin: Plugin<[Partial<RehypeCustomEmojiOptions>], Root>;
|
||||
export default plugin;
|
1
dist/index.d.ts.map
vendored
Normal file
1
dist/index.d.ts.map
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACtC,OAAO,KAAK,EAAS,IAAI,EAAW,MAAM,MAAM,CAAC;AAOjD,UAAU,wBAAwB;IAChC,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACzC,IAAI,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACxB;AAQD,QAAA,MAAM,MAAM,EAAE,MAAM,CAAC,CAAC,OAAO,CAAC,wBAAwB,CAAC,CAAC,EAAE,IAAI,CA0B7D,CAAC;AAEF,eAAe,MAAM,CAAC"}
|
34
dist/index.js
vendored
Normal file
34
dist/index.js
vendored
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { findAndReplace } from "hast-util-find-and-replace";
|
||||
import { h } from "hastscript";
|
||||
const RE_EMOJI = /:([\w-]+):/g;
|
||||
const DEFAULT_OPTIONS = {
|
||||
sourceFile: "/assets/emojis.png",
|
||||
hidpiSourceFile: undefined,
|
||||
emojis: {},
|
||||
size: 64,
|
||||
};
|
||||
const plugin = (options) => {
|
||||
const settings = Object.assign({}, DEFAULT_OPTIONS, options);
|
||||
const srcset = settings.hidpiSourceFile
|
||||
? `${settings.sourceFile} 1x, ${settings.hidpiSourceFile} 2x`
|
||||
: undefined;
|
||||
const emojiElement = (x, y, label) => {
|
||||
return h("img", {
|
||||
src: settings.sourceFile,
|
||||
srcset,
|
||||
style: `object-fit: none; object-position: -${x}px -${y}px; width: ${settings.size}px; height: ${settings.size}px`,
|
||||
alt: label,
|
||||
title: label,
|
||||
});
|
||||
};
|
||||
const replaceEmoji = (_, match) => {
|
||||
if (!(match in settings.emojis))
|
||||
return false;
|
||||
const emoji = settings.emojis[match];
|
||||
return emojiElement(emoji[0], emoji[1], match);
|
||||
};
|
||||
return (tree) => {
|
||||
findAndReplace(tree, [RE_EMOJI, replaceEmoji]);
|
||||
};
|
||||
};
|
||||
export default plugin;
|
141
generate/generate.js
Normal file
141
generate/generate.js
Normal file
|
@ -0,0 +1,141 @@
|
|||
/**
|
||||
* Generates a sprite sheet and emoji JSON file for use by the plugin.
|
||||
* Takes one argument, the root directory of your emoji files.
|
||||
* This makes a few assumptions:
|
||||
* - All emojis are PNG files.
|
||||
* - All emojis are the same size.
|
||||
* - All emojis are static.
|
||||
* - No filenames have spaces.
|
||||
*/
|
||||
|
||||
import { exec as nodeExec } from "node:child_process";
|
||||
import { argv } from "node:process";
|
||||
import { promisify } from "node:util";
|
||||
import * as fs from "node:fs";
|
||||
import { join as joinPath } from "node:path";
|
||||
|
||||
const writeFile = promisify(fs.writeFile);
|
||||
const readDir = promisify(fs.readdir);
|
||||
const exec = promisify(nodeExec);
|
||||
|
||||
let directory = ".";
|
||||
let outname = "spritesheet";
|
||||
let scale = 0;
|
||||
if (argv.length < 2) {
|
||||
throw "Not enough arguments, need a folder to process";
|
||||
}
|
||||
|
||||
if (argv[0].indexOf("node") !== -1 && argv[1].indexOf("generate.js") !== -1) {
|
||||
if (argv.length < 3) throw "Not enough arguments, need a folder to process";
|
||||
directory = argv[2];
|
||||
if (argv.length > 3) outname = argv[3];
|
||||
if (argv.length > 4) scale = parseInt(argv[4]);
|
||||
} else {
|
||||
directory = argv[1];
|
||||
if (argv.length > 2) outname = argv[2];
|
||||
if (argv.length > 3) scale = parseInt(argv[3]);
|
||||
}
|
||||
|
||||
async function getFiles() {
|
||||
return (await readDir(directory))
|
||||
.filter((f) => f.endsWith(".png"))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
console.log(`Directory: ${directory}
|
||||
Output files: ${outname}.png, ${outname}.json
|
||||
Scale: ${scale}`);
|
||||
|
||||
const files = await getFiles();
|
||||
const { size } = await createSpriteSheet(files);
|
||||
await createJSON(files, size);
|
||||
|
||||
/**
|
||||
* @param {string[]} files
|
||||
*/
|
||||
async function createSpriteSheet(files) {
|
||||
const size = Math.ceil(Math.sqrt(files.length));
|
||||
|
||||
// i don't wanna pull in any more dependencies, so lol
|
||||
if (!scale) {
|
||||
const { stdout: rawImageSize } = await exec(
|
||||
[
|
||||
"magick",
|
||||
"identify",
|
||||
"-ping",
|
||||
"-format",
|
||||
"'%w'",
|
||||
joinPath(directory, files[0]),
|
||||
].join(" ")
|
||||
);
|
||||
scale = parseInt(rawImageSize);
|
||||
}
|
||||
|
||||
const { stdout, stderr } = await exec(
|
||||
[
|
||||
"magick",
|
||||
"montage",
|
||||
"-tile",
|
||||
`${size}x${size}`,
|
||||
"-geometry",
|
||||
"+0+0",
|
||||
"-scale",
|
||||
`${scale}`,
|
||||
"-background",
|
||||
"transparent",
|
||||
...files.map((f) => joinPath(directory, f)),
|
||||
`${outname}.png`,
|
||||
].join(" ")
|
||||
);
|
||||
if (stderr) {
|
||||
console.error("Error from ImageMagick:", stderr);
|
||||
return;
|
||||
}
|
||||
if (stdout) console.log(stdout);
|
||||
|
||||
const { stdout2, stderr2 } = await exec(
|
||||
[
|
||||
"magick",
|
||||
"montage",
|
||||
"-tile",
|
||||
`${size}x${size}`,
|
||||
"-geometry",
|
||||
"+0+0",
|
||||
"-scale",
|
||||
`${scale * 2}`,
|
||||
"-background",
|
||||
"transparent",
|
||||
...files.map((f) => joinPath(directory, f)),
|
||||
`${outname}_2x.png`,
|
||||
].join(" ")
|
||||
);
|
||||
if (stderr2) {
|
||||
console.error("Error from ImageMagick:", stderr2);
|
||||
return;
|
||||
}
|
||||
if (stdout2) console.log(stdout2);
|
||||
|
||||
return { size };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} files The image filenames.
|
||||
* @param {number} size The size of the sprite sheet in number of sprites horizontally and vertically.
|
||||
*/
|
||||
async function createJSON(files, size) {
|
||||
let json = {
|
||||
source: `${outname}.png`,
|
||||
hidpiSource: `${outname}_2x.png`,
|
||||
size: scale,
|
||||
emojis: {},
|
||||
};
|
||||
|
||||
files.forEach((filename, index) => {
|
||||
const shortcode = filename.split(".", 2)[0];
|
||||
const xPos = Math.floor(index % size);
|
||||
const yPos = Math.floor(index / size);
|
||||
json.emojis[shortcode] = [xPos * scale, yPos * scale];
|
||||
});
|
||||
|
||||
await writeFile(`${outname}.json`, JSON.stringify(json, undefined, " "));
|
||||
}
|
38
package.json
Normal file
38
package.json
Normal file
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"name": "rehype-custom-emoji-plugin",
|
||||
"version": "0.1.0",
|
||||
"description": "Rehype plugin to convert custom emoji shortcodes to images",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "sam <sam@vulpine.solutions>",
|
||||
"license": "ISC",
|
||||
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0",
|
||||
"devDependencies": {
|
||||
"@tsconfig/node-lts": "^22.0.1",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@types/node": "^22.13.14",
|
||||
"rehype": "^13.0.2",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rehype-stringify": "^10.0.1",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.1",
|
||||
"remark-stringify": "^11.0.0",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.2",
|
||||
"unified": "^11.0.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"hast-util-find-and-replace": "^5.0.1",
|
||||
"hastscript": "^9.0.1",
|
||||
"mdast-util-find-and-replace": "^3.0.2"
|
||||
}
|
||||
}
|
1067
pnpm-lock.yaml
generated
Normal file
1067
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
55
src/index.ts
Normal file
55
src/index.ts
Normal file
|
@ -0,0 +1,55 @@
|
|||
import type { Plugin } from "unified";
|
||||
import type { Nodes, Root, Element } from "hast";
|
||||
|
||||
import { findAndReplace } from "hast-util-find-and-replace";
|
||||
import { h } from "hastscript";
|
||||
|
||||
const RE_EMOJI = /:([\w-]+):/g;
|
||||
|
||||
interface RehypeCustomEmojiOptions {
|
||||
sourceFile: string;
|
||||
hidpiSourceFile?: string;
|
||||
emojis: Record<string, [number, number]>;
|
||||
size: number;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: RehypeCustomEmojiOptions = {
|
||||
sourceFile: "/assets/emojis.png",
|
||||
hidpiSourceFile: undefined,
|
||||
emojis: {},
|
||||
size: 64,
|
||||
};
|
||||
|
||||
const plugin: Plugin<[Partial<RehypeCustomEmojiOptions>], Root> = (options) => {
|
||||
const settings: RehypeCustomEmojiOptions = Object.assign(
|
||||
{},
|
||||
DEFAULT_OPTIONS,
|
||||
options
|
||||
);
|
||||
|
||||
const srcset = settings.hidpiSourceFile
|
||||
? `${settings.sourceFile} 1x, ${settings.hidpiSourceFile} 2x`
|
||||
: undefined;
|
||||
const emojiElement = (x: number, y: number, label: string): Element => {
|
||||
return h("img", {
|
||||
src: settings.sourceFile,
|
||||
srcset,
|
||||
style: `object-fit: none; object-position: -${x}px -${y}px; width: ${settings.size}px; height: ${settings.size}px`,
|
||||
alt: label,
|
||||
title: label,
|
||||
});
|
||||
};
|
||||
|
||||
const replaceEmoji = (_: string, match: string): string | false | Element => {
|
||||
if (!(match in settings.emojis)) return false;
|
||||
const emoji = settings.emojis[match];
|
||||
|
||||
return emojiElement(emoji[0], emoji[1], match);
|
||||
};
|
||||
|
||||
return (tree: Nodes): void => {
|
||||
findAndReplace(tree, [RE_EMOJI, replaceEmoji]);
|
||||
};
|
||||
};
|
||||
|
||||
export default plugin;
|
8
tsconfig.json
Normal file
8
tsconfig.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "@tsconfig/node-lts/tsconfig.json",
|
||||
"include": ["src/**/*"],
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"outDir": "dist"
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue