From ef08c53ba99238272c85ddf7f2a2eb4d280278b9 Mon Sep 17 00:00:00 2001 From: Rodrigo Fernandes Date: Mon, 7 Oct 2019 15:41:38 +0100 Subject: [PATCH] refactor: Migrate scripts to Typescript --- package.json | 27 ++- ...wser-bundle.sh => build-browser-bundle.sh} | 0 scripts/{release-css.sh => build-css.sh} | 0 ...elease-templates.sh => build-templates.sh} | 2 +- .../{release-website.sh => build-website.sh} | 2 +- scripts/build-website.ts | 54 +++++ scripts/hulk.js | 212 ------------------ scripts/hulk.ts | 191 ++++++++++++++++ scripts/release-website.js | 72 ------ tsconfig.scripts.json | 10 + yarn.lock | 17 ++ 11 files changed, 290 insertions(+), 297 deletions(-) rename scripts/{release-browser-bundle.sh => build-browser-bundle.sh} (100%) rename scripts/{release-css.sh => build-css.sh} (100%) rename scripts/{release-templates.sh => build-templates.sh} (82%) rename scripts/{release-website.sh => build-website.sh} (96%) create mode 100644 scripts/build-website.ts delete mode 100755 scripts/hulk.js create mode 100755 scripts/hulk.ts delete mode 100644 scripts/release-website.js create mode 100644 tsconfig.scripts.json diff --git a/package.json b/package.json index 57afd00..f307b84 100644 --- a/package.json +++ b/package.json @@ -35,20 +35,20 @@ "node": ">=4" }, "scripts": { - "build": "tsc", "lint": "eslint '*/**/*.{js,jsx,ts,tsx}'", "style": "yarn run lint", "test": "jest", "coverage": "jest --collectCoverage", "coverage-html": "yarn run coverage && open ./coverage/index.html", "codacy": "cat ./coverage/lcov.info | codacy-coverage", - "release": "yarn run release-css && yarn run release-templates && yarn run release-ts && yarn run release-browser-bundle && yarn run release-website", - "release-css": "./scripts/release-css.sh", - "release-templates": "./scripts/release-templates.sh", - "release-ts": "yarn run build", - "release-browser-bundle": "./scripts/release-browser-bundle.sh", - "release-website": "./scripts/release-website.sh", - "preversion": "yarn run release && yarn run lint && yarn test", + "build": "rm -rf build; yarn run build-scripts && yarn run build-css && yarn run build-templates && yarn run build-library && yarn run build-browser-bundle && yarn run build-website", + "build-scripts": "tsc -p tsconfig.scripts.json", + "build-css": "./scripts/build-css.sh", + "build-templates": "./scripts/build-templates.sh", + "build-library": "tsc -p tsconfig.json", + "build-browser-bundle": "./scripts/build-browser-bundle.sh", + "build-website": "./scripts/build-website.sh", + "preversion": "yarn run build && yarn run lint && yarn test", "version": "git add -A package.json", "postversion": "git push && git push --tags" }, @@ -63,8 +63,11 @@ "whatwg-fetch": "^3.0.0" }, "devDependencies": { + "@types/hogan.js": "^3.0.0", "@types/jest": "24.0.18", + "@types/mkdirp": "^0.5.2", "@types/node": "^12.7.2", + "@types/nopt": "^3.0.29", "@typescript-eslint/eslint-plugin": "2.0.0", "@typescript-eslint/parser": "2.0.0", "autoprefixer": "^9.6.0", @@ -86,15 +89,17 @@ "nopt": "^4.0.1", "postcss-cli": "^6.1.3", "prettier": "1.18.2", + "terser": "^4.3.8", "ts-jest": "24.0.2", - "typescript": "^3.6.3", - "terser": "^4.3.8" + "typescript": "^3.6.3" }, "resolutions": { "lodash": "4.17.15" }, "license": "MIT", "files": [ - "build" + "build/commonjs-node", + "build/browser", + "build/css" ] } diff --git a/scripts/release-browser-bundle.sh b/scripts/build-browser-bundle.sh similarity index 100% rename from scripts/release-browser-bundle.sh rename to scripts/build-browser-bundle.sh diff --git a/scripts/release-css.sh b/scripts/build-css.sh similarity index 100% rename from scripts/release-css.sh rename to scripts/build-css.sh diff --git a/scripts/release-templates.sh b/scripts/build-templates.sh similarity index 82% rename from scripts/release-templates.sh rename to scripts/build-templates.sh index 061b105..e8f2ce4 100755 --- a/scripts/release-templates.sh +++ b/scripts/build-templates.sh @@ -4,7 +4,7 @@ set -e SCRIPT_DIRECTORY="$( cd "$( dirname "$0" )" && pwd )" -node ${SCRIPT_DIRECTORY}/hulk.js \ +node ${SCRIPT_DIRECTORY}/../build/scripts/hulk.js \ --wrapper node \ --variable 'browserTemplates' \ ${SCRIPT_DIRECTORY}/../src/templates/*.mustache > ${SCRIPT_DIRECTORY}/../src/diff2html-templates.js diff --git a/scripts/release-website.sh b/scripts/build-website.sh similarity index 96% rename from scripts/release-website.sh rename to scripts/build-website.sh index 82573ef..6e4ce1e 100755 --- a/scripts/release-website.sh +++ b/scripts/build-website.sh @@ -31,7 +31,7 @@ echo "Minifying ${OUTPUT_DEMO_JS} to ${OUTPUT_DEMO_MIN_JS}" terser ${OUTPUT_DEMO_JS} -c -o ${OUTPUT_DEMO_MIN_JS} echo "Generating HTMLs from templates ..." -node ${SCRIPT_DIRECTORY}/release-website.js +node ${SCRIPT_DIRECTORY}/../build/scripts/build-website.js echo "Copying static files ..." cp -rf ${INPUT_DIR}/img ${OUTPUT_DIR}/ diff --git a/scripts/build-website.ts b/scripts/build-website.ts new file mode 100644 index 0000000..1114fcf --- /dev/null +++ b/scripts/build-website.ts @@ -0,0 +1,54 @@ +#!/usr/bin/env node + +import * as fs from "fs"; + +import * as hogan from "hogan.js"; + +type OptionsType = { + all: object; + [page: string]: object; +}; + +const templatesRoot = "website/templates"; +const pagesRoot = `${templatesRoot}/pages`; +const options: OptionsType = { + all: { + demoUrl: "demo.html?diff=https://github.com/rtfpessoa/diff2html/pull/106" + }, + demo: { + extraClass: "template-index-min" + } +}; + +function readFile(filePath: string): string { + try { + return fs.readFileSync(filePath, "utf8"); + } catch (_ignore) {} + + return ""; +} + +function writeFile(filePath: string, content: string): void { + return fs.writeFileSync(filePath, content); +} + +const websitePages = fs.readdirSync(pagesRoot); + +const template = hogan.compile(readFile(`${templatesRoot}/template.mustache`)); + +websitePages.map(page => { + const baseOptions = { ...(options.all || {}), ...(options[page] || {}) }; + + const pagePartialTemplate = hogan.compile(readFile(`${pagesRoot}/${page}/${page}.partial.mustache`)); + const pageAssetsTemplate = hogan.compile(readFile(`${pagesRoot}/${page}/${page}-assets.partial.mustache`)); + const pageScriptsTemplate = hogan.compile(readFile(`${pagesRoot}/${page}/${page}-scripts.partial.mustache`)); + + const pagePartial = pagePartialTemplate.render(baseOptions); + const pageAssets = pageAssetsTemplate.render(baseOptions); + const pageScripts = pageScriptsTemplate.render(baseOptions); + + const pageOptions = { ...baseOptions, assets: pageAssets, scripts: pageScripts, content: pagePartial }; + + const pageHtml = template.render(pageOptions); + writeFile(`docs/${page}.html`, pageHtml); +}); diff --git a/scripts/hulk.js b/scripts/hulk.js deleted file mode 100755 index d718a66..0000000 --- a/scripts/hulk.js +++ /dev/null @@ -1,212 +0,0 @@ -#!/usr/bin/env node - -/* - * Copyright 2011 Twitter, Inc. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// dependencies -const path = require("path"); -const fs = require("fs"); -const hogan = require("hogan.js"); -const nopt = require("nopt"); -const mkderp = require("mkdirp"); - -// locals -const specials = ["/", ".", "*", "+", "?", "|", "(", ")", "[", "]", "{", "}", "\\"]; -const specialsRegExp = new RegExp("(\\" + specials.join("|\\") + ")", "g"); -let options = { - namespace: String, - outputdir: path, - variable: String, - wrapper: String, - version: true, - help: true -}; -const shortHand = { - n: ["--namespace"], - o: ["--outputdir"], - vn: ["--variable"], - w: ["--wrapper"], - h: ["--help"], - v: ["--version"] -}; -let templates; - -// options -options = nopt(options, shortHand); - -// escape special regexp characters -function esc(text) { - return text.replace(specialsRegExp, "\\$1"); -} - -// cyan function for rob -function cyan(text) { - return "\x1B[36m" + text + "\x1B[39m"; -} - -// check for dirs and correct ext (<3 for windows) -function extractFiles(args) { - const usage = - "\n" + - cyan("USAGE:") + - " hulk [--wrapper wrapper] [--outputdir outputdir] " + - "[--namespace namespace] [--variable variable] FILES\n\n" + - cyan("OPTIONS:") + - " [-w, --wrapper] :: wraps the template (i.e. amd)\n" + - " [-o, --outputdir] :: outputs the templates as individual files to a directory\n\n" + - " [-n, --namespace] :: prepend string to template names\n\n" + - " [-vn, --variable] :: variable name for non-amd wrapper\n\n" + - cyan("EXAMPLE:") + - " hulk --wrapper amd ./templates/*.mustache\n\n" + - cyan("NOTE:") + - ' hulk supports the "*" wildcard and allows you to target specific extensions too\n'; - let files = []; - - if (options.version) { - console.log(require("../package.json").version); - process.exit(0); - } - - if (!args.length || options.help) { - console.log(usage); - process.exit(0); - } - - args.forEach(function(arg) { - if (/\*/.test(arg)) { - arg = arg.split("*"); - files = files.concat( - fs - .readdirSync(arg[0] || ".") - .map(function(f) { - const file = path.join(arg[0], f); - return new RegExp(esc(arg[1]) + "$").test(f) && fs.statSync(file).isFile() && file; - }) - .filter(function(f) { - return f; - }) - ); - return files; - } - - if (fs.statSync(arg).isFile()) files.push(arg); - }); - - return files; -} - -// remove utf-8 byte order mark, http://en.wikipedia.org/wiki/Byte_order_mark -function removeByteOrderMark(text) { - if (text.charCodeAt(0) === 0xfeff) { - return text.substring(1); - } - return text; -} - -// wrap templates -function wrap(file, name, openedFile) { - switch (options.wrapper) { - case "amd": - return ( - "define(" + - (!options.outputdir ? '"' + path.join(path.dirname(file), name) + '", ' : "") + - '[ "hogan.js" ], function(Hogan){ return new Hogan.Template(' + - hogan.compile(openedFile, { asString: 1 }) + - ");});" - ); - case "node": - var globalObj = "global." + (options.variable || "templates") + '["' + name + '"]'; - var globalStmt = globalObj + " = new Hogan.Template(" + hogan.compile(openedFile, { asString: 1 }) + ");"; - var nodeOutput = globalStmt; - - // if we have a template per file the export will expose the template directly - if (options.outputdir) { - nodeOutput = nodeOutput + "\n" + "module.exports = " + globalObj + ";"; - } - - return nodeOutput; - default: - return ( - (options.variable || "templates") + - '["' + - name + - '"] = new Hogan.Template(' + - hogan.compile(openedFile, { asString: 1 }) + - ");" - ); - } -} - -function prepareOutput(content) { - const variableName = options.variable || "templates"; - switch (options.wrapper) { - case "amd": - return content; - case "node": - var nodeExport = ""; - - // if we have aggregated templates the export will expose the template map - if (!options.outputdir) { - nodeExport = "module.exports = global." + variableName + ";\n"; - } - - return ( - "(function() {\n" + - "if (!!!global." + - variableName + - ") global." + - variableName + - " = {};\n" + - 'var Hogan = require("hogan.js");' + - content + - "\n" + - nodeExport + - "})();" - ); - default: - return "if (!!!" + variableName + ") var " + variableName + " = {};\n" + content; - } -} - -// write the directory -if (options.outputdir) { - mkderp.sync(options.outputdir); -} - -// Prepend namespace to template name -function namespace(name) { - return (options.namespace || "") + name; -} - -// write a template foreach file that matches template extension -templates = extractFiles(options.argv.remain) - .map(function(file) { - let openedFile = fs.readFileSync(file, "utf-8").trim(); - let name; - if (!openedFile) return; - name = namespace(path.basename(file).replace(/\..*$/, "")); - openedFile = removeByteOrderMark(openedFile); - openedFile = wrap(file, name, openedFile); - if (!options.outputdir) return openedFile; - fs.writeFileSync(path.join(options.outputdir, name + ".js"), prepareOutput(openedFile)); - }) - .filter(function(t) { - return t; - }); - -// output templates -if (!templates.length || options.outputdir) process.exit(0); - -console.log(prepareOutput(templates.join("\n"))); diff --git a/scripts/hulk.ts b/scripts/hulk.ts new file mode 100755 index 0000000..b668fb6 --- /dev/null +++ b/scripts/hulk.ts @@ -0,0 +1,191 @@ +#!/usr/bin/env node + +/* + * Copyright 2011 Twitter, Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as path from "path"; +import * as fs from "fs"; + +import * as hogan from "hogan.js"; +import * as nopt from "nopt"; +import * as mkderp from "mkdirp"; + +const options = nopt( + { + namespace: String, + outputdir: path, + variable: String, + wrapper: String, + version: true, + help: true + }, + { + n: ["--namespace"], + o: ["--outputdir"], + vn: ["--variable"], + w: ["--wrapper"], + h: ["--help"], + v: ["--version"] + } +); + +const specials = ["/", ".", "*", "+", "?", "|", "(", ")", "[", "]", "{", "}", "\\"]; +const specialsRegExp = new RegExp("(\\" + specials.join("|\\") + ")", "g"); +function escape(text: string): string { + return text.replace(specialsRegExp, "\\$1"); +} + +function cyan(text: string): string { + return "\x1B[36m" + text + "\x1B[39m"; +} + +function extractFiles(files: string[]): string[] { + const usage = ` + ${cyan( + "USAGE:" + )} hulk [--wrapper wrapper] [--outputdir outputdir] [--namespace namespace] [--variable variable] FILES + + ${cyan("OPTIONS:")} [-w, --wrapper] :: wraps the template (i.e. amd) + [-o, --outputdir] :: outputs the templates as individual files to a directory + + [-n, --namespace] :: prepend string to template names + + [-vn, --variable] :: variable name for non-amd wrapper + + ${cyan("EXAMPLE:")} hulk --wrapper amd ./templates/*.mustache + + ${cyan("NOTE:")} hulk supports the "*" wildcard and allows you to target specific extensions too + `; + + if (options.version) { + console.log(require("../package.json").version); + process.exit(0); + } + + if (!files.length || options.help) { + console.log(usage); + process.exit(0); + } + + const templateFiles = files + .map((fileGlob: string) => { + if (/\*/.test(fileGlob)) { + const [fileGlobPrefix, fileGlobSuffix] = fileGlob.split("*"); + + const files = fs.readdirSync(fileGlobPrefix || ".").reduce((previousFiles, relativeFilePath) => { + const file = path.join(fileGlobPrefix, relativeFilePath); + if (new RegExp(`${escape(fileGlobSuffix)}$`).test(relativeFilePath) && fs.statSync(file).isFile()) { + previousFiles.push(file); + } + return previousFiles; + }, []); + + return files; + } else if (fs.statSync(fileGlob).isFile()) { + return [fileGlob]; + } else { + return []; + } + }) + .reduce((previous, current) => previous.concat(current), []); + + return templateFiles; +} + +// Remove utf-8 byte order mark, http://en.wikipedia.org/wiki/Byte_order_mark +function removeByteOrderMark(text: string): string { + if (text.charCodeAt(0) === 0xfeff) { + return text.substring(1); + } + return text; +} + +// Wrap templates +function wrap(file: string, name: string, openedFile: string): string { + const hoganTemplateString = `new Hogan.Template(${hogan.compile(openedFile, { asString: true })})`; + + const objectName = options.variable || "templates"; + const objectAccessor = `${objectName}["${name}"]`; + const objectStmt = `${objectAccessor} = ${hoganTemplateString};`; + + switch (options.wrapper) { + case "amd": + return `define(${ + !options.outputdir ? `"${path.join(path.dirname(file), name)}", ` : "" + }["hogan.js"], function(Hogan) { return ${hoganTemplateString}; });`; + + case "node": + // If we have a template per file the export will expose the template directly + return options.outputdir ? `global.${objectStmt};\nmodule.exports = ${objectAccessor};` : `global.${objectStmt}`; + + default: + return objectStmt; + } +} + +function prepareOutput(content: string): string { + const variableName = options.variable || "templates"; + switch (options.wrapper) { + case "amd": + return content; + case "node": + return ( + "(function() {\n" + + "if (!!!global." + + variableName + + ") global." + + variableName + + " = {};\n" + + 'var Hogan = require("hogan.js");' + + content + + "\n" + + (!options.outputdir ? "module.exports = global." + variableName + ";\n" : "") + + "})();" + ); + default: + return "if (!!!" + variableName + ") var " + variableName + " = {};\n" + content; + } +} + +// Write the directory +if (options.outputdir) { + mkderp.sync(options.outputdir); +} + +// Prepend namespace to template name +function namespace(name: string): string { + return (options.namespace || "") + name; +} + +// Write a template foreach file that matches template extension +const templates = extractFiles(options.argv.remain) + .map(file => { + const timmedFileContents = fs.readFileSync(file, "utf-8").trim(); + + if (!timmedFileContents) return; + + const name = namespace(path.basename(file).replace(/\..*$/, "")); + const cleanFileContents = wrap(file, name, removeByteOrderMark(timmedFileContents)); + + if (!options.outputdir) return cleanFileContents; + + return fs.writeFileSync(path.join(options.outputdir, `${name}.js`), prepareOutput(cleanFileContents)); + }) + .filter(templateContents => typeof templateContents !== "undefined"); + +// Output templates +if (!templates.length || options.outputdir) process.exit(0); + +console.log(prepareOutput(templates.join("\n"))); diff --git a/scripts/release-website.js b/scripts/release-website.js deleted file mode 100644 index 8c5ff0f..0000000 --- a/scripts/release-website.js +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env node - -const fs = require("fs"); - -const hogan = require("hogan.js"); - -const root = "website/templates"; -const pagesRoot = root + "/pages"; - -const websitePages = fs.readdirSync(root + "/pages"); - -const template = hogan.compile(readFile(root + "/template.mustache")); - -const options = { - all: { - demoUrl: "demo.html?diff=https://github.com/rtfpessoa/diff2html/pull/106" - }, - demo: { - extraClass: "template-index-min" - } -}; - -websitePages.map(function(page) { - const pagePartialTemplate = hogan.compile(readFile(pagesRoot + "/" + page + "/" + page + ".partial.mustache")); - const pageAssetsTemplate = hogan.compile(readFile(pagesRoot + "/" + page + "/" + page + "-assets.partial.mustache")); - const pageScriptsTemplate = hogan.compile( - readFile(pagesRoot + "/" + page + "/" + page + "-scripts.partial.mustache") - ); - - const templateOptions = {}; - - let key; - - // Allow the pages to share common options - const genericOptions = options.all || {}; - for (key in genericOptions) { - if (genericOptions.hasOwnProperty(key)) { - templateOptions[key] = genericOptions[key]; - } - } - - // Allow each page to have custom options - const pageOptions = options[page] || {}; - for (key in pageOptions) { - if (pageOptions.hasOwnProperty(key)) { - templateOptions[key] = pageOptions[key]; - } - } - - const pagePartial = pagePartialTemplate.render(templateOptions); - const pageAssets = pageAssetsTemplate.render(templateOptions); - const pageScripts = pageScriptsTemplate.render(templateOptions); - - templateOptions.assets = pageAssets; - templateOptions.scripts = pageScripts; - templateOptions.content = pagePartial; - - const pageHtml = template.render(templateOptions); - writeFile("docs/" + page + ".html", pageHtml); -}); - -function readFile(filePath) { - try { - return fs.readFileSync(filePath, "utf8"); - } catch (_ignore) {} - - return ""; -} - -function writeFile(filePath, content) { - return fs.writeFileSync(filePath, content); -} diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json new file mode 100644 index 0000000..cae940e --- /dev/null +++ b/tsconfig.scripts.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./build/scripts", + "declaration": false, + "declarationMap": false, + "sourceMap": false + }, + "include": ["./scripts/**/*"] +} diff --git a/yarn.lock b/yarn.lock index 1c76a08..fb4a42c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -351,6 +351,11 @@ "@types/minimatch" "*" "@types/node" "*" +"@types/hogan.js@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/hogan.js/-/hogan.js-3.0.0.tgz#bf26560f39a38224ab6d0491b06f72c8fbe0953d" + integrity sha512-djkvb/AN43c3lIGCojNQ1FBS9VqqKhcTns5RQnHw4xBT/csy0jAssAsOiJ8NfaaioZaeKYE7XkVRxE5NeSZcaA== + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0": version "2.0.1" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz#42995b446db9a48a11a07ec083499a860e9138ff" @@ -393,11 +398,23 @@ resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA== +"@types/mkdirp@^0.5.2": + version "0.5.2" + resolved "https://registry.yarnpkg.com/@types/mkdirp/-/mkdirp-0.5.2.tgz#503aacfe5cc2703d5484326b1b27efa67a339c1f" + integrity sha512-U5icWpv7YnZYGsN4/cmh3WD2onMY0aJIiTE6+51TwJCttdHvtCYmkBNOobHlXwrJRL0nkH9jH4kD+1FAdMN4Tg== + dependencies: + "@types/node" "*" + "@types/node@*", "@types/node@^12.7.2": version "12.7.11" resolved "https://registry.yarnpkg.com/@types/node/-/node-12.7.11.tgz#be879b52031cfb5d295b047f5462d8ef1a716446" integrity sha512-Otxmr2rrZLKRYIybtdG/sgeO+tHY20GxeDjcGmUnmmlCWyEnv2a2x1ZXBo3BTec4OiTXMQCiazB8NMBf0iRlFw== +"@types/nopt@^3.0.29": + version "3.0.29" + resolved "https://registry.yarnpkg.com/@types/nopt/-/nopt-3.0.29.tgz#f19df3db4c97ee1459a2740028320a71d70964ce" + integrity sha1-8Z3z20yX7hRZonQAKDIKcdcJZM4= + "@types/stack-utils@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e"