template-replacement 3.3.3 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/.editorconfig +8 -0
  2. package/.oxfmtrc.jsonc +7 -0
  3. package/.oxlintrc.json +3 -0
  4. package/README.md +39 -9
  5. package/core/base.ts +54 -55
  6. package/core/general.ts +37 -9
  7. package/core/sign.ts +43 -8
  8. package/dispatcher/general.ts +2 -1
  9. package/dispatcher/sign.ts +2 -1
  10. package/dispatcher/workerGeneral.ts +1 -1
  11. package/dispatcher/workerSign.ts +1 -1
  12. package/dist/base-DQz39fXI.js +249 -0
  13. package/dist/index-oILo_kXG.js +46 -0
  14. package/dist/main/general.js +1334 -1384
  15. package/dist/main/sign.js +1476 -1514
  16. package/dist/replace/general.js +283 -313
  17. package/dist/replace/sign.js +309 -327
  18. package/download/index.ts +13 -13
  19. package/download/stream.ts +29 -28
  20. package/eslint.config.ts +28 -0
  21. package/fileSystem/db/index.ts +25 -25
  22. package/fileSystem/db/indexedDBCache.ts +145 -143
  23. package/fileSystem/index.ts +5 -8
  24. package/fileSystem/interface.ts +4 -5
  25. package/fileSystem/opfs/index.ts +40 -36
  26. package/helper/index.ts +136 -125
  27. package/index.ts +6 -6
  28. package/office/zip.ts +106 -97
  29. package/package.json +14 -8
  30. package/replace/base.ts +203 -222
  31. package/replace/general.ts +44 -24
  32. package/replace/image.ts +88 -90
  33. package/replace/interface.ts +29 -24
  34. package/replace/paramsData.ts +107 -95
  35. package/replace/sign.ts +79 -52
  36. package/task/urlDownloadTask.ts +53 -55
  37. package/temp/index.ts +139 -124
  38. package/temp/interface.ts +8 -8
  39. package/tsconfig.json +1 -1
  40. package/vite.config.ts +11 -14
  41. package/worker/child/agency.ts +49 -41
  42. package/worker/child/base.ts +125 -89
  43. package/worker/child/general.ts +2 -3
  44. package/worker/child/sign.ts +4 -4
  45. package/worker/index.ts +52 -53
  46. package/worker/interface.ts +9 -6
  47. package/worker/main/general.ts +5 -5
  48. package/worker/main/index.ts +191 -66
  49. package/worker/main/sign.ts +5 -5
  50. package/worker/type.ts +16 -15
  51. package/dist/assets/template_replacement_core_wasm_bg.wasm +0 -0
  52. package/dist/assets/template_replacement_sign_core_wasm_bg.wasm +0 -0
  53. package/dist/base-CJv023nf.js +0 -284
  54. package/dist/general.d.ts +0 -1
  55. package/dist/index-tFDVIkZX.js +0 -46
  56. package/dist/sign.d.ts +0 -1
package/helper/index.ts CHANGED
@@ -2,176 +2,187 @@ import urlDownloadTask from '../task/urlDownloadTask'
2
2
  import { fileTypeFromBuffer } from 'file-type'
3
3
 
4
4
  export function urlSuffix(url: string): string {
5
- url = url.split('?')[0]
6
- if (url.lastIndexOf('.') === -1) {
7
- return ''
8
- }
9
- return url.substring(url.lastIndexOf('.') + 1)
5
+ url = url.split('?')[0]
6
+ if (url.lastIndexOf('.') === -1) {
7
+ return ''
8
+ }
9
+ return url.substring(url.lastIndexOf('.') + 1)
10
10
  }
11
11
 
12
- export function getFileNameFromUrl(url: string): string {
13
- url = url.split('?')[0]
14
- const pathParts = url.split('/')
15
- return pathParts[pathParts.length - 1]
12
+ export function getFileNameFromUrl(url: string): string {
13
+ url = url.split('?')[0]
14
+ const pathParts = url.split('/')
15
+ return pathParts[pathParts.length - 1]
16
16
  }
17
17
 
18
- export const enum fileTypes {
19
- word = 'word',
20
- excel = 'excel',
21
- unknown = 'unknown',
18
+ export const enum fileTypes {
19
+ word = 'word',
20
+ excel = 'excel',
21
+ unknown = 'unknown',
22
22
  }
23
23
 
24
24
  export const officeMIMETypes: Record<string, fileTypes> = {
25
- //docx
26
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': fileTypes.word,
27
- //dotx
28
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.template': fileTypes.word,
29
- //xlsx
30
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': fileTypes.excel,
25
+ //docx
26
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': fileTypes.word,
27
+ //dotx
28
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.template': fileTypes.word,
29
+ //xlsx
30
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': fileTypes.excel,
31
31
  }
32
32
 
33
33
  export const officeSuffixTypes: Record<string, fileTypes> = {
34
- 'docx': fileTypes.word,
35
- 'dotx': fileTypes.word,
36
- 'xlsx': fileTypes.excel,
34
+ docx: fileTypes.word,
35
+ dotx: fileTypes.word,
36
+ xlsx: fileTypes.excel,
37
37
  }
38
38
 
39
39
  export function fileType(file: File): fileTypes {
40
- return officeMIMETypes[file.type] ?? fileTypes.unknown
40
+ return officeMIMETypes[file.type] ?? fileTypes.unknown
41
41
  }
42
42
 
43
43
  export function fileTypeByName(name: string): fileTypes {
44
- return officeSuffixTypes[urlSuffix(name)] ?? fileTypes.unknown
44
+ return officeSuffixTypes[urlSuffix(name)] ?? fileTypes.unknown
45
45
  }
46
46
 
47
- export async function fileTypeByBuffer(buffer: Uint8Array|ArrayBuffer|Blob): Promise<fileTypes> {
48
- if (buffer instanceof Blob) {
49
- if (buffer.type && officeMIMETypes[buffer.type]) {
50
- return officeMIMETypes[buffer.type]
51
- }
52
- buffer = await buffer.arrayBuffer()
47
+ export async function fileTypeByBuffer(
48
+ buffer: Uint8Array | ArrayBuffer | Blob,
49
+ ): Promise<fileTypes> {
50
+ if (buffer instanceof Blob) {
51
+ if (buffer.type && officeMIMETypes[buffer.type]) {
52
+ return officeMIMETypes[buffer.type]
53
53
  }
54
- const type = await fileTypeFromBuffer(buffer)
54
+ buffer = await buffer.arrayBuffer()
55
+ }
56
+ const type = await fileTypeFromBuffer(buffer)
55
57
 
56
- if (type && officeMIMETypes[type.mime]) {
57
- return officeMIMETypes[type.mime]
58
- }
59
- return fileTypes.unknown
58
+ if (type && officeMIMETypes[type.mime]) {
59
+ return officeMIMETypes[type.mime]
60
+ }
61
+ return fileTypes.unknown
60
62
  }
61
63
 
62
64
  export function generateId(): string {
63
- return crypto.randomUUID()
65
+ return crypto.randomUUID()
64
66
  }
65
67
 
66
68
  export type fileArrayBufferData = {
67
- name: string
68
- buffer: ArrayBuffer
69
- }
70
-
71
- export function filesReaderArrayBuffer(files: File[]): Promise<fileArrayBufferData[]> {
72
- const awaits: Promise<fileArrayBufferData>[] = []
73
- for (const file of files) {
74
- awaits.push(new Promise((resolve, reject) => {
75
- try {
76
- file.arrayBuffer().then(buffer => {
77
- resolve({
78
- name: file.name,
79
- buffer
80
- })
81
- }).catch(reject)
82
- } catch (error) {
83
- reject(error)
84
- }
85
- }))
86
- }
87
- return Promise.all(awaits)
69
+ name: string
70
+ buffer: ArrayBuffer
71
+ }
72
+
73
+ export function filesReaderArrayBuffer(
74
+ files: File[],
75
+ ): Promise<fileArrayBufferData[]> {
76
+ const awaits: Promise<fileArrayBufferData>[] = []
77
+ for (const file of files) {
78
+ awaits.push(
79
+ new Promise((resolve, reject) => {
80
+ try {
81
+ file
82
+ .arrayBuffer()
83
+ .then((buffer) => {
84
+ resolve({
85
+ name: file.name,
86
+ buffer,
87
+ })
88
+ })
89
+ .catch(reject)
90
+ } catch (error) {
91
+ reject(error)
92
+ }
93
+ }),
94
+ )
95
+ }
96
+ return Promise.all(awaits)
88
97
  }
89
98
 
90
-
91
99
  export type fileBase64Data = {
92
- name: string
93
- base64: string
100
+ name: string
101
+ base64: string
94
102
  }
95
103
 
96
104
  export function filesReaderBase64(files: File[]): Promise<fileBase64Data[]> {
97
- const awaits: Promise<fileBase64Data>[] = []
98
- for (const file of files) {
99
- awaits.push(new Promise((resolve, reject) => {
100
- const fileReader = new FileReader()
101
- fileReader.onload = function(e) {
102
- let base64 = e.target?.result as string
103
- if (!base64) {
104
- resolve({
105
- name: file.name,
106
- base64: ''
107
- })
108
- return
109
- }
110
- const index = base64.indexOf(",")
111
- if (index != -1) {
112
- base64 = base64.slice(index + 1)
113
- }
114
- resolve({
115
- name: file.name,
116
- base64: base64
117
- })
118
- }
119
- fileReader.onerror = function (e) {
120
- reject(e)
121
- }
122
- fileReader.readAsDataURL(file)
123
- }))
124
- }
125
- return Promise.all(awaits)
105
+ const awaits: Promise<fileBase64Data>[] = []
106
+ for (const file of files) {
107
+ awaits.push(
108
+ new Promise((resolve, reject) => {
109
+ const fileReader = new FileReader()
110
+ fileReader.onload = function (e) {
111
+ let base64 = e.target?.result as string
112
+ if (!base64) {
113
+ resolve({
114
+ name: file.name,
115
+ base64: '',
116
+ })
117
+ return
118
+ }
119
+ const index = base64.indexOf(',')
120
+ if (index != -1) {
121
+ base64 = base64.slice(index + 1)
122
+ }
123
+ resolve({
124
+ name: file.name,
125
+ base64: base64,
126
+ })
127
+ }
128
+ fileReader.onerror = function (e) {
129
+ reject(e)
130
+ }
131
+ fileReader.readAsDataURL(file)
132
+ }),
133
+ )
134
+ }
135
+ return Promise.all(awaits)
126
136
  }
127
137
 
128
138
  export function base64ToBlob(base64: string): Blob {
129
- const arr = base64.split(',')
130
- let mime
131
- if (arr.length > 1) {
132
- const m: RegExpMatchArray | null = arr[0].match(/:(.*?);/)
133
- if (m?.length) {
134
- mime = m[1]
135
- base64 = arr[1]
136
- }
137
- }
138
- if (!mime) {
139
- mime = 'application/octet-stream'
139
+ const arr = base64.split(',')
140
+ let mime
141
+ if (arr.length > 1) {
142
+ const m: RegExpMatchArray | null = arr[0].match(/:(.*?);/)
143
+ if (m?.length) {
144
+ mime = m[1]
145
+ base64 = arr[1]
140
146
  }
147
+ }
148
+ if (!mime) {
149
+ mime = 'application/octet-stream'
150
+ }
141
151
 
142
- const byteCharacters = atob(base64)
143
- const byteArray = new Uint8Array(byteCharacters.length)
144
- for (let i = 0; i < byteCharacters.length; i++) {
145
- byteArray[i] = byteCharacters.charCodeAt(i)
146
- }
147
- return new Blob([byteArray], { type: mime })
152
+ const byteCharacters = atob(base64)
153
+ const byteArray = new Uint8Array(byteCharacters.length)
154
+ for (let i = 0; i < byteCharacters.length; i++) {
155
+ byteArray[i] = byteCharacters.charCodeAt(i)
156
+ }
157
+ return new Blob([byteArray], { type: mime })
148
158
  }
149
159
 
150
160
  //urls提取为文件二进制数据
151
- export async function urlsToFileBlobs(urls: string[], onDownloadProgress?: (progressEvent: any) => void): Promise<(Blob|undefined)[]> {
152
- const task = new urlDownloadTask(urls)
153
- if(onDownloadProgress) {
154
- task.onDownloadProgress(onDownloadProgress)
155
- }
156
- return await task.start()
161
+ export function urlsToFileBlobs(
162
+ urls: string[],
163
+ onDownloadProgress?: (progressEvent: unknown) => void,
164
+ ): Promise<(Blob | undefined)[]> {
165
+ const task = new urlDownloadTask(urls)
166
+ if (onDownloadProgress) {
167
+ task.onDownloadProgress(onDownloadProgress)
168
+ }
169
+ return task.start()
157
170
  }
158
171
 
159
172
  export function splitArrayIntoChunks<T>(array: T[], chunkSize: number): T[][] {
160
- const result: T[][] = []
161
- for (let i = 0; i < array.length; i += chunkSize) {
162
- const chunk = array.slice(i, i + chunkSize)
163
- result.push(chunk)
164
- }
165
- return result
173
+ const result: T[][] = []
174
+ for (let i = 0; i < array.length; i += chunkSize) {
175
+ const chunk = array.slice(i, i + chunkSize)
176
+ result.push(chunk)
177
+ }
178
+ return result
166
179
  }
167
180
 
168
181
  // 字符串SHA-1哈希值
169
182
  export async function hashString(str: string): Promise<string> {
170
- const encoder = new TextEncoder();
171
- const data = encoder.encode(str);
172
- const hashBuffer = await window.crypto.subtle.digest("SHA-1", data);
173
- const hashArray = Array.from(new Uint8Array(hashBuffer));
174
- return hashArray
175
- .map((b) => b.toString(16).padStart(2, "0"))
176
- .join("");
183
+ const encoder = new TextEncoder()
184
+ const data = encoder.encode(str)
185
+ const hashBuffer = await window.crypto.subtle.digest('SHA-1', data)
186
+ const hashArray = Array.from(new Uint8Array(hashBuffer))
187
+ return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
177
188
  }
package/index.ts CHANGED
@@ -6,13 +6,13 @@ import ReplaceInterface from './replace/interface'
6
6
 
7
7
  export const General = general
8
8
 
9
- export const Sign = sign
9
+ export const Sign = sign
10
10
 
11
11
  export const WorkerGeneral = workerGeneral
12
12
 
13
13
  export const WorkerSign = workerSign
14
14
 
15
- type signFun = (data: any) => Promise<string>;
15
+ type signFun = (data: unknown) => Promise<string>
16
16
 
17
17
  export default (concurrency?: number, signFn?: signFun): ReplaceInterface => {
18
18
  let res = undefined
@@ -20,16 +20,16 @@ export default (concurrency?: number, signFn?: signFun): ReplaceInterface => {
20
20
  if (signFn) {
21
21
  res = workerSign(concurrency)
22
22
  res.sign = signFn
23
- }else{
23
+ } else {
24
24
  res = workerGeneral(concurrency)
25
25
  }
26
- }else {
26
+ } else {
27
27
  if (signFn) {
28
28
  res = sign()
29
29
  res.sign = signFn
30
- }else{
30
+ } else {
31
31
  res = general()
32
32
  }
33
33
  }
34
34
  return res
35
- }
35
+ }
package/office/zip.ts CHANGED
@@ -1,116 +1,125 @@
1
1
  import { stream } from '../download'
2
- import { FlateError, unzip, Unzipped, zip, strToU8, AsyncZipOptions, Zip as fflateZip, ZipDeflate } from 'fflate'
2
+ import {
3
+ FlateError,
4
+ unzip,
5
+ Unzipped,
6
+ zip,
7
+ strToU8,
8
+ AsyncZipOptions,
9
+ Zip as fflateZip,
10
+ ZipDeflate,
11
+ } from 'fflate'
3
12
 
4
- type InputType = string|Uint8Array|ArrayBuffer|Blob
13
+ type InputType = string | Uint8Array | ArrayBuffer | Blob
5
14
 
6
15
  export default class Zip {
7
- name: string = ''
8
- fileBlob?: Blob
9
- _unzipData?: Unzipped
10
- _lastUpdateTime: number = 0;
16
+ name: string = ''
17
+ fileBlob?: Blob
18
+ private _unzipData?: Unzipped
19
+ private _lastUpdateTime: number = 0
11
20
 
12
- constructor(file?: Blob) {
13
- if (!file) {
14
- return
15
- }
16
- this.name = (file as File)?.name ?? ''
17
- this.fileBlob = file
21
+ constructor(file?: Blob) {
22
+ if (!file) {
23
+ return
18
24
  }
25
+ this.name = (file as File)?.name ?? ''
26
+ this.fileBlob = file
27
+ }
19
28
 
20
- getFileBlob(): Blob|undefined {
21
- return this.fileBlob
22
- }
29
+ getFileBlob(): Blob | undefined {
30
+ return this.fileBlob
31
+ }
23
32
 
24
- async fileZip(): Promise<Unzipped> {
25
- if (!this._unzipData) {
26
- try {
27
- const blob = this.getFileBlob()
28
- if (blob) {
29
- const arrayBuffer = await blob.arrayBuffer()
30
- this._unzipData = await new Promise((resolve, reject) => {
31
- unzip(new Uint8Array(arrayBuffer), (err: FlateError | null, data: Unzipped) => {
32
- if (err) {
33
- return reject(err)
34
- }
35
- resolve(data)
36
- })
37
- })
38
- }
39
- } catch (e) {
40
- console.warn(e)
41
- }
42
- }
43
- if (!this._unzipData) {
44
- this._unzipData = {}
33
+ async fileZip(): Promise<Unzipped> {
34
+ if (!this._unzipData) {
35
+ try {
36
+ const blob = this.getFileBlob()
37
+ if (blob) {
38
+ const arrayBuffer = await blob.arrayBuffer()
39
+ this._unzipData = await new Promise((resolve, reject) => {
40
+ unzip(
41
+ new Uint8Array(arrayBuffer),
42
+ (err: FlateError | null, data: Unzipped) => {
43
+ resolve(data)
44
+ },
45
+ )
46
+ })
45
47
  }
46
- return this._unzipData
48
+ } catch (e) {
49
+ console.warn(e)
50
+ }
47
51
  }
48
-
49
- async setZipData(path: string, data: InputType): Promise<void> {
50
- const fileZip = await this.fileZip()
51
- switch (true) {
52
- case data instanceof Blob:
53
- data = new Uint8Array(await data.arrayBuffer())
54
- break
55
- case data instanceof Uint8Array:
56
- break
57
- case data instanceof ArrayBuffer:
58
- data = new Uint8Array(data)
59
- break
60
- case data instanceof String:
61
- data = strToU8(data as string)
62
- break
63
- default:
64
- throw new Error('Invalid data type')
65
- }
66
- fileZip[path] = data as Uint8Array
67
- this._lastUpdateTime = (new Date()).getTime()
52
+ if (!this._unzipData) {
53
+ this._unzipData = {}
68
54
  }
55
+ return this._unzipData
56
+ }
69
57
 
70
- async generate(options: AsyncZipOptions): Promise<Blob|undefined> {
71
- const data = await this.fileZip()
72
- if (!Object.keys(data).length) {
73
- return undefined
74
- }
75
- return await new Promise((resolve, reject) => {
76
- zip(data, options, (err: FlateError | null, data: Uint8Array) => {
77
- if (err) {
78
- return reject(err)
79
- }
80
- resolve(new Blob([data as BlobPart]))
81
- })
82
- })
58
+ async setZipData(path: string, data: InputType): Promise<void> {
59
+ const fileZip = await this.fileZip()
60
+ switch (true) {
61
+ case data instanceof Blob:
62
+ data = new Uint8Array(await data.arrayBuffer())
63
+ break
64
+ case data instanceof Uint8Array:
65
+ break
66
+ case data instanceof ArrayBuffer:
67
+ data = new Uint8Array(data)
68
+ break
69
+ case data instanceof String:
70
+ data = strToU8(data as string)
71
+ break
72
+ default:
73
+ throw new Error('Invalid data type')
83
74
  }
75
+ fileZip[path] = data as Uint8Array
76
+ this._lastUpdateTime = new Date().getTime()
77
+ }
84
78
 
85
- async download(fileName: string): Promise<void> {
86
- const data = await this.fileZip()
87
- if (!Object.keys(data).length) {
88
- return
79
+ async generate(options: AsyncZipOptions): Promise<Blob | undefined> {
80
+ const data = await this.fileZip()
81
+ if (!Object.keys(data).length) {
82
+ return undefined
83
+ }
84
+ return await new Promise((resolve, reject) => {
85
+ zip(data, options, (err: FlateError | null, data: Uint8Array) => {
86
+ if (err) {
87
+ return reject(err)
89
88
  }
89
+ resolve(new Blob([data as BlobPart]))
90
+ })
91
+ })
92
+ }
90
93
 
91
- if (fileName == undefined) {
92
- fileName = this.name
93
- }
94
+ async download(fileName: string): Promise<void> {
95
+ const data = await this.fileZip()
96
+ if (!Object.keys(data).length) {
97
+ return
98
+ }
94
99
 
95
- const downloadStream = stream(fileName)
96
- const zip = new fflateZip((err, dat, final) => {
97
- if (err) {
98
- downloadStream.abort(err)
99
- return
100
- }
101
- downloadStream.write(dat)
102
- if (final) {
103
- downloadStream.close()
104
- }
105
- })
100
+ if (fileName == undefined) {
101
+ fileName = this.name
102
+ }
106
103
 
107
- for (const key in data) {
108
- const deflate = new ZipDeflate(key, {
109
- level: 9
110
- });
111
- zip.add(deflate)
112
- deflate.push(data[key], true);
113
- }
114
- zip.end()
104
+ const downloadStream = stream(fileName)
105
+ const zip = new fflateZip((err, dat, final) => {
106
+ if (err) {
107
+ downloadStream.abort(err)
108
+ return
109
+ }
110
+ downloadStream.write(dat)
111
+ if (final) {
112
+ downloadStream.close()
113
+ }
114
+ })
115
+
116
+ for (const key in data) {
117
+ const deflate = new ZipDeflate(key, {
118
+ level: 9,
119
+ })
120
+ zip.add(deflate)
121
+ deflate.push(data[key], true)
115
122
  }
116
- }
123
+ zip.end()
124
+ }
125
+ }
package/package.json CHANGED
@@ -1,31 +1,37 @@
1
1
  {
2
2
  "name": "template-replacement",
3
3
  "description": "模板文件替换",
4
- "version": "3.3.3",
4
+ "version": "3.4.0",
5
5
  "author": "fushiliang <994301536@qq.com>",
6
6
  "type": "module",
7
7
  "main": "index.ts",
8
8
  "license": "Apache 2.0",
9
9
  "scripts": {
10
- "build": "npx vite build --config vite.config.ts"
10
+ "build": "vite build --config vite.config.ts",
11
+ "lint": "npx oxlint && npx eslint"
11
12
  },
12
13
  "dependencies": {
13
14
  "axios": "^1.13.2",
14
15
  "fflate": "^0.8.2",
15
16
  "file-type": "^19.6.0",
16
17
  "streamsaver": "^2.0.6",
17
- "template-replacement-core-wasm": "^1.4.0",
18
- "template-replacement-sign-core-wasm": "^1.4.0"
18
+ "template-replacement-core-wasm": "^1.7.2",
19
+ "template-replacement-sign-core-wasm": "^1.7.2"
19
20
  },
20
21
  "devDependencies": {
22
+ "@rollup/pluginutils": "^5.3.0",
21
23
  "@types/streamsaver": "^2.0.5",
24
+ "@typescript-eslint/eslint-plugin": "^8.50.0",
25
+ "@typescript-eslint/parser": "^8.50.0",
26
+ "eslint": "^9.39.2",
27
+ "eslint-plugin-oxlint": "^1.33.0",
28
+ "oxfmt": "^0.16.0",
29
+ "oxlint": "^1.33.0",
22
30
  "terser": "^5.44.1",
23
31
  "typescript": "^5.9.3",
24
- "vite": "^7.2.2",
25
- "vite-plugin-dts": "^4.5.4",
26
- "vite-plugin-wasm-pack": "^0.1.12"
32
+ "vite": "^7.3.0"
27
33
  },
28
34
  "keywords": [
29
35
  "template-replacement"
30
36
  ]
31
- }
37
+ }