@beforesemicolon/builder 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
package/.eslintignore ADDED
@@ -0,0 +1 @@
1
+ src/**/*.spec.ts
package/.eslintrc.cjs ADDED
@@ -0,0 +1,13 @@
1
+ module.exports = {
2
+ extends: [
3
+ 'eslint:recommended',
4
+ 'plugin:@typescript-eslint/recommended',
5
+ 'prettier',
6
+ ],
7
+ parser: '@typescript-eslint/parser',
8
+ plugins: ['@typescript-eslint'],
9
+ root: true,
10
+ rules: {
11
+ 'no-prototype-builtins': ['off'],
12
+ },
13
+ }
@@ -0,0 +1,2 @@
1
+ src/**/*.spec.ts
2
+ src/docs/**/*.html
package/.prettierrc ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "trailingComma": "es5",
3
+ "tabWidth": 4,
4
+ "semi": false,
5
+ "singleQuote": true
6
+ }
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@beforesemicolon/builder",
3
+ "version": "1.0.0",
4
+ "description": "Utilities to build npm packages and documentation website",
5
+ "engines": {
6
+ "node": ">=18.16.0"
7
+ },
8
+ "type": "module",
9
+ "scripts": {
10
+ "build": "rm -rf dist && npm-run-all lint && tsc",
11
+ "lint": "eslint ./src && prettier --check .",
12
+ "format": "eslint ./src --fix && prettier --write ."
13
+ },
14
+ "author": "Elson Correia",
15
+ "license": "BSD-3-Clause",
16
+ "repository": {
17
+ "url": "https://github.com/beforesemicolon/builder",
18
+ "type": "git"
19
+ },
20
+ "keywords": [
21
+ "builder",
22
+ "build npm",
23
+ "docs"
24
+ ],
25
+ "funding": {
26
+ "url": "https://github.com/sponsors/beforesemicolon",
27
+ "type": "github"
28
+ },
29
+ "devDependencies": {
30
+ "@gjsify/esbuild-plugin-transform-ext": "0.0.4",
31
+ "@types/jest": "^29.5.11",
32
+ "@types/jsdom": "^21.1.6",
33
+ "@types/jsdom-global": "^3.0.7",
34
+ "@types/node": "^20.10.5",
35
+ "@types/node-sass": "^4.11.7",
36
+ "@typescript-eslint/eslint-plugin": "^6.15.0",
37
+ "@typescript-eslint/parser": "^6.15.0",
38
+ "core-js": "^3.34.0",
39
+ "esbuild": "^0.19.10",
40
+ "esbuild-plugin-text-replace": "^1.3.0",
41
+ "eslint": "^8.56.0",
42
+ "eslint-config-prettier": "^9.1.0",
43
+ "eslint-config-standard": "^17.1.0",
44
+ "eslint-plugin-import": "^2.29.1",
45
+ "eslint-plugin-n": "^16.5.0",
46
+ "eslint-plugin-prettier": "^5.1.2",
47
+ "eslint-plugin-promise": "^6.1.1",
48
+ "front-matter": "^4.0.2",
49
+ "global-jsdom": "^9.2.0",
50
+ "highlight.js": "^11.10.0",
51
+ "install": "^0.13.0",
52
+ "isomorphic-dompurify": "^2.16.0",
53
+ "jsdom": "^23.0.1",
54
+ "marked": "^14.1.2",
55
+ "marked-highlight": "^2.1.4",
56
+ "nodemon": "^3.1.7",
57
+ "npm": "^10.8.3",
58
+ "npm-run-all": "^4.1.5",
59
+ "prettier": "3.1.1",
60
+ "tinybench": "^2.8.0",
61
+ "ts-jest": "^29.1.1",
62
+ "tsx": "^4.7.0",
63
+ "typescript": "^5.3.3"
64
+ }
65
+ }
@@ -0,0 +1,35 @@
1
+ import esbuild, { Plugin } from 'esbuild'
2
+
3
+ // the Doc exported with @beforesemicolon/html-parser is not used so no need
4
+ // to include it in the final package
5
+ const emptyParserDoc = {
6
+ name: 'empty-parser-Doc-plugin',
7
+ setup(build) {
8
+ build.onResolve({ filter: /Doc$/ }, (args) => {
9
+ if (args.importer.includes('@beforesemicolon/html-parser')) {
10
+ return {
11
+ path: args.path,
12
+ namespace: 'empty-Doc',
13
+ }
14
+ }
15
+ })
16
+ build.onLoad({ filter: /.*/, namespace: 'empty-Doc' }, () => {
17
+ return {
18
+ contents: '',
19
+ }
20
+ })
21
+ },
22
+ } as Plugin
23
+
24
+ esbuild
25
+ .build({
26
+ entryPoints: ['src/client.ts'],
27
+ outfile: 'dist/client.js',
28
+ bundle: true,
29
+ keepNames: true,
30
+ sourcemap: true,
31
+ target: 'esnext',
32
+ minify: true,
33
+ plugins: [emptyParserDoc],
34
+ })
35
+ .catch(console.error)
@@ -0,0 +1,53 @@
1
+ import esbuild from 'esbuild'
2
+ import path from 'path'
3
+ import fs from 'fs'
4
+ import { transformExtPlugin } from '@gjsify/esbuild-plugin-transform-ext'
5
+
6
+ function readFilesRecursively(directoryPath: string) {
7
+ const files: string[] = []
8
+
9
+ function readDirectory(currentPath: string) {
10
+ const entries = fs.readdirSync(currentPath, { withFileTypes: true })
11
+
12
+ for (const entry of entries) {
13
+ const fullPath = path.join(currentPath, entry.name)
14
+
15
+ if (entry.isDirectory()) {
16
+ readDirectory(fullPath)
17
+ } else {
18
+ files.push(fullPath)
19
+ }
20
+ }
21
+ }
22
+
23
+ readDirectory(directoryPath)
24
+ return files
25
+ }
26
+
27
+ const directoryPath = path.join(process.cwd(), 'src')
28
+ const allFiles = readFilesRecursively(directoryPath).filter(
29
+ (file) => !file.endsWith('.spec.ts') && !file.endsWith('/client.ts')
30
+ )
31
+
32
+ Promise.all([
33
+ esbuild.build({
34
+ entryPoints: allFiles,
35
+ outdir: 'dist/esm',
36
+ target: 'esnext',
37
+ minify: true,
38
+ format: 'esm',
39
+ platform: 'node',
40
+ keepNames: true,
41
+ plugins: [transformExtPlugin({ outExtension: { '.ts': '.js' } })],
42
+ }),
43
+ esbuild.build({
44
+ entryPoints: allFiles,
45
+ outdir: 'dist/cjs',
46
+ target: 'esnext',
47
+ minify: true,
48
+ format: 'cjs',
49
+ platform: 'node',
50
+ keepNames: true,
51
+ plugins: [transformExtPlugin({ outExtension: { '.ts': '.js' } })],
52
+ }),
53
+ ]).catch(console.error)
@@ -0,0 +1,15 @@
1
+ import { Tokens } from 'marked'
2
+
3
+ export default function code({ lang, text }: Tokens.Code) {
4
+ return `<div class="code-snippet">
5
+ ${lang ? `<div class="label ${lang.toLowerCase()}">${lang}</div>` : ''}
6
+ <div class="content">
7
+ <pre>
8
+ <code class="hljs language-javascript">${text}</code>
9
+ </pre>
10
+ </div>
11
+ <button type="button" class="code-copy-btn" style="visibility: hidden">
12
+ copy
13
+ </button>
14
+ </div>`
15
+ }
@@ -0,0 +1,13 @@
1
+ import { Tokens } from 'marked'
2
+
3
+ export default function heading({ tokens, depth }: Tokens.Heading) {
4
+ // @ts-expect-error this
5
+ const text = this.parser.parseInline(tokens)
6
+ const escapedText = text
7
+ .toLowerCase()
8
+ .replace(/[^\w]+/g, ' ')
9
+ .trim()
10
+ .replace(/\s/g, '-')
11
+
12
+ return `<h${depth} id="${escapedText}"><a href="#${escapedText}">${text}</a></h${depth}>`
13
+ }
@@ -0,0 +1,10 @@
1
+ import heading from './heading.js'
2
+ import code from './code.js'
3
+ import link from './link.js'
4
+ import { RendererObject } from 'marked'
5
+
6
+ export default {
7
+ heading,
8
+ code,
9
+ link,
10
+ } as unknown as RendererObject
@@ -0,0 +1,7 @@
1
+ import { Tokens } from 'marked'
2
+
3
+ export default function link({ href, text }: Tokens.Link) {
4
+ return `<a href="${href
5
+ .replace(/\.md/, '.html')
6
+ .replace(/index\.html/, '')}">${text}</a>`
7
+ }
@@ -0,0 +1,212 @@
1
+ import { Marked } from 'marked'
2
+ import { cp, mkdir, readdir, readFile, writeFile } from 'fs/promises'
3
+ import path from 'path'
4
+ import fm from 'front-matter'
5
+ import DOMPurify from 'isomorphic-dompurify'
6
+ import { markedHighlight } from 'marked-highlight'
7
+ import hljs from 'highlight.js'
8
+ import defaultTemp from './templates/default.js'
9
+ import { CustomOptions, PageProps, SiteMap } from './types.js'
10
+ import renderer from './renderer/index.js'
11
+
12
+ const layouts: Map<string, (props: PageProps) => string> = new Map()
13
+
14
+ layouts.set('default', defaultTemp)
15
+
16
+ let currentFileMeta: {
17
+ attributes: CustomOptions
18
+ siteMap: SiteMap
19
+ }
20
+
21
+ const marked = new Marked(
22
+ markedHighlight({
23
+ langPrefix: 'hljs language-',
24
+ highlight(code, lang) {
25
+ const language = hljs.getLanguage(lang) ? lang : 'plaintext'
26
+ return hljs.highlight(code, { language }).value
27
+ },
28
+ })
29
+ )
30
+
31
+ marked.use({ renderer })
32
+
33
+ const docsDir = path.resolve(process.cwd(), 'docs')
34
+ const docsSiteDir = path.resolve(process.cwd(), 'website')
35
+ const docsLayoutsDir = path.resolve(process.cwd(), 'docs/_layouts')
36
+ const docsAssetsDir = path.resolve(process.cwd(), 'docs/assets')
37
+ const docsStylesheetsDir = path.resolve(process.cwd(), 'docs/stylesheets')
38
+ const docsScriptsDir = path.resolve(process.cwd(), 'docs/scripts')
39
+
40
+ const traverseDirectory = async (dir: string) => {
41
+ const items = await readdir(dir)
42
+ const files: string[] = []
43
+
44
+ for (const item of items) {
45
+ const ext = path.extname(item)
46
+
47
+ if (/^[._]/.test(item)) continue
48
+
49
+ if (ext) {
50
+ files.push(path.join(dir, item))
51
+ } else if (!/(stylesheets|assets|scripts)/.test(item)) {
52
+ files.push(...(await traverseDirectory(path.join(dir, item))))
53
+ }
54
+ }
55
+
56
+ return files
57
+ }
58
+
59
+ marked.use({
60
+ hooks: {
61
+ postprocess(html: string) {
62
+ const { layout = 'default', ...options } =
63
+ currentFileMeta.attributes
64
+
65
+ html = DOMPurify.sanitize(html)
66
+
67
+ const tableOfContent = [
68
+ ...html.matchAll(
69
+ /<h([0-6])\sid="[^"]+".*?>.*?<a\s+href="([^"]+)".*?>([^<]+)<\/a>/gm
70
+ ),
71
+ ].map((m) => ({ path: m[2], label: m[3], level: m[1] }))
72
+
73
+ return (
74
+ layouts.get(layout)?.({
75
+ ...options,
76
+ content: html,
77
+ siteMap: currentFileMeta.siteMap,
78
+ tableOfContent,
79
+ }) || html
80
+ )
81
+ },
82
+ },
83
+ })
84
+ ;(async () => {
85
+ // create website dir
86
+ await mkdir(docsSiteDir, { recursive: true })
87
+
88
+ try {
89
+ await cp(docsAssetsDir, docsAssetsDir.replace(docsDir, docsSiteDir), {
90
+ recursive: true,
91
+ })
92
+ } catch (e) {
93
+ // ignore
94
+ }
95
+
96
+ try {
97
+ await cp(
98
+ docsStylesheetsDir,
99
+ docsStylesheetsDir.replace(docsDir, docsSiteDir),
100
+ { recursive: true }
101
+ )
102
+ } catch (e) {
103
+ // ignore
104
+ }
105
+
106
+ try {
107
+ await cp(docsScriptsDir, docsScriptsDir.replace(docsDir, docsSiteDir), {
108
+ recursive: true,
109
+ })
110
+ } catch (e) {
111
+ // ignore
112
+ }
113
+
114
+ try {
115
+ // import the layouts first
116
+ const layoutPaths = await traverseDirectory(docsLayoutsDir)
117
+
118
+ for (const layoutPath of layoutPaths) {
119
+ if (/\.(j|t)s$/.test(layoutPath)) {
120
+ const fileName = path
121
+ .basename(layoutPath)
122
+ .replace(path.extname(layoutPath), '')
123
+ const handler = await import(layoutPath)
124
+
125
+ layouts.set(fileName, handler.default)
126
+ }
127
+ }
128
+ } catch (e) {
129
+ // ignore
130
+ }
131
+
132
+ const filePaths = await traverseDirectory(docsDir)
133
+
134
+ const siteMap: SiteMap = new Map()
135
+
136
+ ;(
137
+ await Promise.all(
138
+ filePaths
139
+ .filter((filePath) => filePath.endsWith('.md'))
140
+ .map(async (filePath) => {
141
+ const content = await readFile(filePath, 'utf-8')
142
+ return {
143
+ filePath,
144
+ // @ts-expect-error call unknown
145
+ ...(fm(content) as {
146
+ attributes: CustomOptions
147
+ body: string
148
+ }),
149
+ }
150
+ })
151
+ )
152
+ )
153
+ .sort((a, b) => a.attributes.order - b.attributes.order)
154
+ .map(({ attributes, body, filePath }) => {
155
+ const pathname = filePath
156
+ .replace(docsDir, '')
157
+ .replace(/\.md/, '.html')
158
+ const fileWebsitePath = filePath
159
+ .replace(docsDir, docsSiteDir)
160
+ .replace('.md', '.html')
161
+ const fileDirWebsitePath = fileWebsitePath.replace(
162
+ path.basename(fileWebsitePath),
163
+ ''
164
+ )
165
+
166
+ const fileName = path.basename(pathname) || '/'
167
+ const dir = pathname.replace(fileName, '').replace(/\/$/, '')
168
+
169
+ let currentDir = siteMap
170
+
171
+ dir.split('/')
172
+ .filter(Boolean)
173
+ .forEach((d) => {
174
+ if (!currentDir.has(d)) {
175
+ currentDir.set(d, new Map())
176
+ }
177
+
178
+ currentDir = currentDir.get(d) as SiteMap
179
+ })
180
+
181
+ const attrs = {
182
+ ...attributes,
183
+ path: pathname,
184
+ } as CustomOptions
185
+
186
+ if (!currentDir.has(fileName)) {
187
+ currentDir.set(fileName, attrs)
188
+ }
189
+
190
+ return {
191
+ attributes: attrs,
192
+ fileWebsitePath,
193
+ fileDirWebsitePath,
194
+ body,
195
+ siteMap,
196
+ }
197
+ })
198
+ .forEach(
199
+ async ({
200
+ attributes,
201
+ body,
202
+ fileDirWebsitePath,
203
+ fileWebsitePath,
204
+ siteMap,
205
+ }) => {
206
+ currentFileMeta = { attributes, siteMap }
207
+ const contentMd = await marked.parse(body)
208
+ await mkdir(fileDirWebsitePath, { recursive: true })
209
+ await writeFile(fileWebsitePath, contentMd)
210
+ }
211
+ )
212
+ })()
@@ -0,0 +1,19 @@
1
+ import { PageProps } from '../types.js'
2
+
3
+ export default ({ title, description, content }: PageProps) => `
4
+ <!doctype html>
5
+ <html lang="en">
6
+ <head>
7
+ <meta charset="UTF-8" />
8
+ <meta
9
+ name="viewport"
10
+ content="width=device-width, initial-scale=1.0"
11
+ />
12
+ <meta name="description" content="${description}" />
13
+ <title>${title}</title>
14
+ </head>
15
+ <body>
16
+ ${content}
17
+ </body>
18
+ </html>
19
+ `
@@ -0,0 +1,16 @@
1
+ export interface PageProps {
2
+ name: string
3
+ path: string
4
+ order: number
5
+ title: string
6
+ description: string
7
+ content: string
8
+ siteMap: SiteMap
9
+ tableOfContent: { path: string; label: string }[]
10
+ }
11
+
12
+ export interface CustomOptions extends Omit<PageProps, 'content'> {
13
+ layout: string
14
+ }
15
+
16
+ export type SiteMap = Map<string, CustomOptions | SiteMap>
package/tsconfig.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2015",
4
+ "types": ["node"],
5
+ "moduleResolution": "nodenext",
6
+ "lib": ["decorators", "es5", "scripthost", "es2015.promise"],
7
+ "outDir": "./dist",
8
+ "module": "nodenext",
9
+ "declaration": true,
10
+ "declarationMap": false,
11
+ "sourceMap": true,
12
+ "removeComments": true,
13
+ "downlevelIteration": true,
14
+ "strict": true,
15
+ "alwaysStrict": true,
16
+ "resolveJsonModule": true,
17
+ "allowSyntheticDefaultImports": true,
18
+ "esModuleInterop": true,
19
+ "skipLibCheck": true,
20
+ "forceConsistentCasingInFileNames": true,
21
+ "allowJs": true
22
+ },
23
+ "include": ["./src/**/*"]
24
+ }