build(tests): add jest tests to plugin & fix code according to them

This commit is contained in:
Falcion 2025-05-11 11:35:37 +03:00
parent 60810db99f
commit 0bafda0d83
21 changed files with 944 additions and 355 deletions

View file

@ -32,6 +32,21 @@ module.exports = {
excludedFiles: ['**/*.ts*', '**/*.*js*'],
parser: 'eslint-plugin-markdownlint/parser',
extends: ['plugin:markdownlint/recommended']
},
{
files: ['jest.setup.js', 'test/**/*.test.ts', '**/__tests__/**/*.[jt]s?(x)'],
env: {
jest: true,
browser: true
},
globals: {
HTMLMediaElement: 'readonly',
HTMLElement: 'readonly',
jest: 'readonly',
describe: 'readonly',
it: 'readonly',
expect: 'readonly'
}
}
]
}

View file

@ -1,4 +1,4 @@
name: Mocha.js CI - tests
name: Jest CI
on:
push:

View file

@ -1,27 +0,0 @@
{
"*.{mjs,js,jsx}": [
"npx prettier --write '*.{mjs,js,jsx}'",
"npx eslint '*.{mjs,js,jsx}' --fix",
"node scripts/lint.js --js --fix --only --",
"npx standard \"*.{mjs,js,jsx}\" --fix"
],
"{!*.d.ts, *.{mts,ts,tsx}}": [
"npx prettier --write '*.{mts,ts,tsx}'",
"npx eslint '*.{mts,ts,tsx}' --fix",
"npx ts-standard \"*.{mts,ts,tsx}\" --fix"
],
"*.d.ts": ["npx prettier --write '**/*.d.ts'"],
"{!CHANGELOG.md, *.md}": [
"npx textlint *.md --fix",
"npx markdownlint-cli2 '!{CHANGELOG.md,node_modules/**,out/**}' '*.{md,mdown,markdown}' --fix"
],
"*.{gn,gni}": ["npm run gn-check", "npm run gn-format"],
"*.{png,jpeg,jpg,gif,svg}": "imagemin-lint-staged",
"*.css": ["stylelint --fix", "prettier --write '*.css'"],
"{*.patch,.patches}": [
"node scripts/lint.js --patches --only --",
"ts-node scripts/js/check-patch-diff.ts"
],
"*.{yml,yaml}": "npx prettier '*.{yml,yaml}' --write",
"*.json": "npx eslint '*.json' --fix"
}

View file

@ -1,28 +0,0 @@
{
"*.{js,jsx}": [
"npx prettier --write '*.{js,jsx}'",
"npx eslint '*.{js,jsx}' --fix",
"node scripts/lint.js --js --fix --only --",
"npx standard \"*.{js,jsx}\" --fix"
],
"{!*.d.ts, *.{ts,tsx}}": [
"npx prettier --write '*.{ts,tsx}'",
"npx eslint '*.{ts,tsx}' --fix",
"npx ts-standard \"*.{ts,tsx}\" --fix"
],
"*.d.ts": ["npx prettier --write '**/*.d.ts'"],
"{!CHANGELOG.md, *.md}": [
"npx textlint *.md --fix",
"npx markdownlint-cli2 '!{CHANGELOG.md,node_modules/**,out/**}' '*.{md,mdown,markdown}' --fix"
],
"*.{gn,gni}": ["npm run gn-check", "npm run gn-format"],
"*.{png,jpeg,jpg,gif,svg}": "imagemin-lint-staged",
"*.css": ["stylelint --fix", "prettier --write '*.css'"],
"{*.patch,.patches}": [
"node scripts/lint.js --patches --only --",
"ts-node scripts/js/check-patch-diff.ts"
],
"*.{yml,yaml}": "npx prettier '*.{yml,yaml}' --write",
"*.json": "npx eslint '*.json' --fix",
"*.{py,ipynb}": ["black . --exclude '**/venv/'", "flake8", "mypy .", "pylint ."]
}

View file

@ -1,8 +1,10 @@
const isMockOrTest = (f) =>
f.includes('__mocks__') || f.endsWith('.test.ts') || f.endsWith('.spec.ts')
/**
* @filename: lint-staged.config.js
* @type {import('lint-staged').Configuration}
*/
export default {
/** Javascript codefiles: */
'*.{mjs,js,jsx,cjs}': (files) => {
@ -24,7 +26,7 @@ export default {
const found = files
.filter(
(f) =>
f.endsWith('.ts') ||
(!isMockOrTest(f) && f.endsWith('.ts')) ||
f.endsWith('.tsx') ||
f.endsWith('.mts') ||
(f.endsWith('.cts') && !f.endsWith('.d.ts'))

42
__mocks__/obsidian.ts Normal file
View file

@ -0,0 +1,42 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable @typescript-eslint/no-floating-promises */
/* eslint-disable @typescript-eslint/strict-boolean-expressions */
export class Plugin {
constructor (public app?: any, public manifest?: any) { }
onload () { }
onunload () { }
}
export interface App {
workspace: any
vault: any
metadataCache: any
}
export interface TFile { path: string }
export class PluginSettingTab {
constructor (public app: App, public plugin: any) { }
display (): void { }
}
export class Setting {
public settingEl = document.createElement('div')
constructor (public containerEl: HTMLElement) {
this.settingEl.classList.add('setting-item')
this.containerEl.appendChild(this.settingEl)
}
setName () { return this }
setDesc () { return this }
addToggle () { return this }
addSlider () { return this }
}
export class TextAreaComponent {
public inputEl = document.createElement('textarea')
constructor (public containerEl: HTMLElement) {
this.inputEl.classList.add('setting-item')
this.containerEl.appendChild(this.inputEl)
}
setPlaceholder () { return this }
setValue () { return this }
onChange () { return this }
}

2
coverage/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*
!.gitignore

847
index.js
View file

@ -5,16 +5,29 @@ if you want to view the source, please visit the github repository of this plugi
*/
'use strict'
var __create = Object.create
var __defProp = Object.defineProperty
var __defProps = Object.defineProperties
var __getOwnPropDesc = Object.getOwnPropertyDescriptor
var __getOwnPropDescs = Object.getOwnPropertyDescriptors
var __getOwnPropNames = Object.getOwnPropertyNames
var __getOwnPropSymbols = Object.getOwnPropertySymbols
var __getProtoOf = Object.getPrototypeOf
var __hasOwnProp = Object.prototype.hasOwnProperty
var __propIsEnum = Object.prototype.propertyIsEnumerable
var __reflectGet = Reflect.get
var __defNormalProp = (obj, key, value) =>
key in obj
? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value })
: (obj[key] = value)
var __spreadValues = (a, b) => {
for (var prop in b || (b = {})) if (__hasOwnProp.call(b, prop)) __defNormalProp(a, prop, b[prop])
if (__getOwnPropSymbols)
for (var prop of __getOwnPropSymbols(b)) {
if (__propIsEnum.call(b, prop)) __defNormalProp(a, prop, b[prop])
}
return a
}
var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b))
var __export = (target, all) => {
for (var name in all) __defProp(target, name, { get: all[name], enumerable: true })
}
@ -29,22 +42,10 @@ var __copyProps = (to, from, except, desc) => {
}
return to
}
var __toESM = (mod, isNodeMode, target) => (
(target = mod != null ? __create(__getProtoOf(mod)) : {}),
__copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule
? __defProp(target, 'default', { value: mod, enumerable: true })
: target,
mod
)
)
var __toCommonJS = (mod) => __copyProps(__defProp({}, '__esModule', { value: true }), mod)
var __publicField = (obj, key, value) =>
__defNormalProp(obj, typeof key !== 'symbol' ? key + '' : key, value)
var __superGet = (cls, obj, key) => __reflectGet(__getProtoOf(cls), key, obj)
var __async = (__this, __arguments, generator) => {
return new Promise((resolve, reject) => {
var fulfilled = (value) => {
@ -67,227 +68,625 @@ var __async = (__this, __arguments, generator) => {
})
}
// index.ts
var index_exports = {}
__export(index_exports, {
CONFIG: () => CONFIG,
LOCALE_LOGGER: () => LOCALE_LOGGER,
ask: () => ask,
default: () => LOCALE_MODULE
// source/main.ts
var main_exports = {}
__export(main_exports, {
default: () => Whisperer
})
module.exports = __toCommonJS(index_exports)
var os = __toESM(require('os'))
var path = __toESM(require('path'))
var fs = __toESM(require('fs-extra'))
var readline = __toESM(require('readline'))
var colors = __toESM(require('colors/safe'))
if (os.type() === 'Darwin') process.abort()
var LOCALE_LOGGER = class {
constructor() {
/**
* A process ID which represents session of localized logger instance.
* @type {number}
*/
__publicField(this, 'session_id', process.ppid)
module.exports = __toCommonJS(main_exports)
var import_obsidian3 = require('obsidian')
// source/settings.ts
var import_obsidian2 = require('obsidian')
// source/settings/settings_class.ts
var import_obsidian = require('obsidian')
var SettingsConstructor = class _SettingsConstructor {
constructor(_plugin, _containerEl) {
__publicField(this, 'defaults')
__publicField(this, '_SETTING_AMBIENCE')
__publicField(this, '_SETTING_VAULT_PATH_TITLE')
__publicField(this, '_SETTING_VAULT_PATH_INPUT')
__publicField(this, '_SETTING_AMBIENCE_PER_FILE')
__publicField(this, '_SETTING_AMBIENCE_PER_FILE_COMMENT')
__publicField(this, '_SETTING_MUSICE_VOLUME')
__publicField(this, '_SETTING_DEBUG_FRAMES')
__publicField(this, 'plugin')
__publicField(this, 'containerEl')
this.plugin = _plugin
this.containerEl = _containerEl
}
/**
* Logs the info message.
* @param {...unknown} data - The data to be logged.
*/
info(...data) {
console.info(colors.blue(this.parseData(data)))
}
/**
* Logs the warn message.
* @param {...unknown} data - The data to be logged.
*/
warn(...data) {
console.warn(colors.yellow(this.parseData(data)))
}
/**
* Logs the error message.
* @param {...unknown} data - The data to be logged.
*/
error(...data) {
console.error(colors.bgRed(colors.white(this.parseData(data))))
}
/**
* Logs the success message.
* @param {...unknown} data - The data to be logged.
*/
success(...data) {
console.log(colors.green(this.parseData(data)))
}
/**
* Logs the message with custom color.
* @param {(str: string) => string} color - The color function.
* @param {...unknown} data - The data to be logged.
*/
raw(color, ...data) {
console.debug(color(this.parseData(data)))
}
/**
* Formats a message with custom color.
* @param {(str: string) => string} color - The color function.
* @param {string} message - The message to be formatted.
* @returns {string} The formatted message.
*/
msg(color, message) {
return color(message)
}
parseData(...data) {
const ctx = data
.map((item) => (typeof item === 'object' ? JSON.stringify(item, null, 2) : String(item)))
.join(' ')
return `[${/* @__PURE__ */ new Date().toLocaleString()}] < ${this.session_id} > - ${ctx}`
}
}
var LOCALE_MODULE = class {
constructor() {
/**
* The root directory of the module.
* @type {string}
*/
__publicField(this, 'ROOT_DIRECTORY', __dirname)
/**
* Directories to be excluded from traversal.
* @type {string[]}
*/
__publicField(this, 'EXCLUDING_FOLDERS', [
'node_modules',
'dist',
'venv',
'.git',
'$git',
'$',
'out',
'bin'
])
/**
* Values to be excluded from file content search.
* @type {string[]}
*/
__publicField(this, 'EXCLUDING_VALUES', [
'FALCION',
'PATTERNU',
'PATTERNUGIT',
'PATTERNUGIT.NET'
])
__publicField(this, 'LOGGER', new LOCALE_LOGGER())
}
/**
* Updates the exclusion settings based on user input.
* @param {string[]} entries - Entries to be added to the exclusion list.
* @param {string} actions - User action (Y or N).
*/
update(entries, actions) {
if (actions.length > 1) {
throw new RangeError('Action input must be a char.')
get SETTING_AMBIENCE() {
if (this._SETTING_AMBIENCE == null) {
this._SETTING_AMBIENCE = new import_obsidian.Setting(this.containerEl)
.setName('Vault ambience:')
.setDesc(
"If enabled, plugin would try to apply your embed URL as audio for the vault's ambience."
)
.addToggle((toggle) => {
toggle.setValue(this.plugin.settings.vault_ambience).onChange((value) =>
__async(this, null, function* () {
const next = __spreadProps(__spreadValues({}, this.plugin.settings), {
vault_ambience: value
})
yield this.plugin.updateSettings(next)
_SettingsConstructor.updateDisplays(
[this.SETTING_VAULT_PATH_TITLE, this.SETTING_VAULT_PATH_INPUT],
[value, value]
)
})
)
return toggle
})
}
if (actions === 'Y') {
for (const entry of entries) {
this.EXCLUDING_VALUES.push(entry)
return this._SETTING_AMBIENCE
}
get SETTING_VAULT_PATH_TITLE() {
if (this._SETTING_VAULT_PATH_TITLE == null) {
this._SETTING_VAULT_PATH_TITLE = new import_obsidian.Setting(this.containerEl)
.setName("Path/URL to the audio of Vault's ambience (supports YouTube, SoundCloud):")
.setDesc(
"Be aware, that some videos on YouTube are blocked to be played as embed, so they may be not working in the Obsidian's Vault."
)
}
return this._SETTING_VAULT_PATH_TITLE
}
get SETTING_VAULT_PATH_INPUT() {
if (this._SETTING_VAULT_PATH_INPUT == null) {
const result = new import_obsidian.TextAreaComponent(this.containerEl)
.setPlaceholder('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
.setValue(this.plugin.settings.vault_ambience_path)
.onChange((value) =>
__async(this, null, function* () {
const next = __spreadValues({}, this.plugin.settings)
next.vault_ambience_path = value
yield this.plugin.updateSettings(next)
})
)
result.inputEl.addClass('input-field')
this._SETTING_VAULT_PATH_INPUT = result
}
return this._SETTING_VAULT_PATH_INPUT
}
get SETTING_AMBIENCE_PER_FILE() {
if (this._SETTING_AMBIENCE_PER_FILE == null) {
this._SETTING_AMBIENCE_PER_FILE = new import_obsidian.Setting(this.containerEl)
.setName('Ambience per files:')
.setDesc(
"If enabled, plugin would try to apply your embed URL as audio for the files's ambience specifically: meaning one file got one ambience and etc., prioritised over vault's ambience (vault ambience would stop playing)."
)
.addToggle((toggle) => {
toggle.setValue(this.plugin.settings.ambience_per_files).onChange((value) =>
__async(this, null, function* () {
const next = __spreadProps(__spreadValues({}, this.plugin.settings), {
ambience_per_files: value
})
yield this.plugin.updateSettings(next)
})
)
return toggle
})
}
return this._SETTING_AMBIENCE_PER_FILE
}
get SETTING_AMBIENCE_PER_FILE_COMMENT() {
if (this._SETTING_AMBIENCE_PER_FILE_COMMENT == null) {
this._SETTING_AMBIENCE_PER_FILE_COMMENT = new import_obsidian.Setting(this.containerEl)
.setName('')
.setDesc(
'To apply custom ambience to file, insert into the metadata cache of the file (YAML) "music" variable with URL or path to your audio.'
)
}
return this._SETTING_AMBIENCE_PER_FILE_COMMENT
}
get SETTING_MUSIC_VOLUME() {
if (this._SETTING_MUSICE_VOLUME == null) {
this._SETTING_MUSICE_VOLUME = new import_obsidian.Setting(this.containerEl)
.setName('Music volume')
.setDesc(
"Control volume of YouTube/Local audios. Audio from SoundCloud doesn't support this feature."
)
.addSlider((slider) => {
slider
.setDynamicTooltip()
.setLimits(
this.plugin.settings.MIN_VOLUME,
this.plugin.settings.MAX_VOLUME,
this.plugin.settings.VOLUME_STEP
)
.onChange((value) =>
__async(this, null, function* () {
const next = __spreadProps(__spreadValues({}, this.plugin.settings), {
music_volume: value
})
this.plugin.updateVolumeSettings(next)
})
)
})
}
return this._SETTING_MUSICE_VOLUME
}
get SETTING_DEBUG_FRAMES() {
if (this._SETTING_DEBUG_FRAMES == null) {
this._SETTING_DEBUG_FRAMES = new import_obsidian.Setting(this.containerEl)
.setName('Debug frames:')
.setDesc(
'Allows you to view frames generated by this plugin, meaning you can view mini-players for music/video.'
)
.addToggle((toggle) => {
toggle.setValue(this.plugin.settings.debug_frames).onChange((value) =>
__async(this, null, function* () {
const next = __spreadProps(__spreadValues({}, this.plugin.settings), {
debug_frames: value
})
yield this.plugin.updateSettings(next)
})
)
})
}
return this._SETTING_DEBUG_FRAMES
}
static updateDisplays(elements, values) {
if (elements.length !== values.length) {
throw new Error('Elements and their values both are out of range in some other ways.')
}
for (let i = 0; i < elements.length; i++) {
const element = elements[i]
if (element instanceof import_obsidian.Setting)
element.settingEl.style.display = values[i] ? 'block' : 'none'
if (element instanceof import_obsidian.TextAreaComponent) {
element.inputEl.style.display = values[i] ? 'block' : 'none'
}
}
if (actions === 'N') {
this.EXCLUDING_FOLDERS = entries
}
_updateState(data, prev, next) {
if (prev !== next) {
if (prev) {
if (this.defaults == null) {
this.defaults = {
color: data.inputEl.style.color,
borderColor: data.inputEl.style.borderColor,
borderWidth: data.inputEl.style.borderWidth
}
}
data.inputEl.style.color = 'red'
data.inputEl.style.borderColor = 'red'
data.inputEl.style.borderWidth = '4px'
} else if (this.defaults != null) {
data.inputEl.style.color = this.defaults.color
data.inputEl.style.borderColor = this.defaults.borderColor
data.inputEl.style.borderWidth = this.defaults.borderWidth
}
}
if (CONFIG.USE_GITIGNORE) {
const gitignore = fs.readFileSync('.gitignore').toString().split('\n')
gitignore.forEach((line) => {
if (line[0] !== '#' && line[0] !== '!') {
this.EXCLUDING_FOLDERS.push(line)
}
}
// source/settings.ts
var WhispererSettingsTab = class extends import_obsidian2.PluginSettingTab {
constructor(app, plugin) {
super(app, plugin)
__publicField(this, 'plugin')
this.plugin = plugin
}
display() {
const { containerEl } = this
containerEl.empty()
const settingsConstructor = new SettingsConstructor(this.plugin, containerEl)
const items = [
settingsConstructor.SETTING_AMBIENCE,
settingsConstructor.SETTING_VAULT_PATH_TITLE,
settingsConstructor.SETTING_VAULT_PATH_INPUT,
settingsConstructor.SETTING_AMBIENCE_PER_FILE,
settingsConstructor.SETTING_AMBIENCE_PER_FILE_COMMENT,
settingsConstructor.SETTING_DEBUG_FRAMES
]
SettingsConstructor.updateDisplays(
[items[1], items[2]],
[this.plugin.settings.vault_ambience, this.plugin.settings.vault_ambience]
)
}
}
// source/settings/settings.ts
var DEFAULT_SETTINGS = {
vault_ambience: false,
vault_ambience_path: '',
ambience_per_files: true,
music_volume: 50,
debug_frames: false,
MIN_VOLUME: 0,
MAX_VOLUME: 100,
VOLUME_STEP: 1
}
// source/utils/functions.ts
function isUrl(str) {
return /^(https?:\/\/)/.test(str)
}
function getEmbedUrl(url) {
if (url.includes('youtube.com') || url.includes('youtu.be')) {
const videoId = url.includes('youtu.be')
? url.split('/').pop()
: new URL(url).searchParams.get('v')
return `https://www.youtube.com/embed/${videoId}?autoplay=1&start=0`
} else if (new URL(url).host === 'soundcloud.com' || new URL(url).host === 'www.soundcloud.com') {
return `https://w.soundcloud.com/player/?url=${encodeURIComponent(url)}&auto_play=true`
}
throw new Error('Unsupported URL type.')
}
function extractId(url) {
const match = url.match(/(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|.*v=))([\w-]{11})/)
return match ? match[1] : ''
}
// source/player/player_per_file.ts
var PlayerPerFile = class {
constructor(_plugin) {
__publicField(this, 'plugin')
this.plugin = _plugin
}
handleFileOpen(file) {
return __async(this, null, function* () {
var _a
if (file == null) return
if (!this.plugin.settings.ambience_per_files) return
const fileCache = this.plugin.app.metadataCache.getFileCache(file)
const musicPath =
(_a = fileCache == null ? void 0 : fileCache.frontmatter) == null ? void 0 : _a.music
if (musicPath) {
this.pauseVaultAmbience()
if (this.plugin.activeFile) {
this.pauseFileAmbience(this.plugin.activeFile)
}
this.plugin.activeFile = file.path
this.playFileAmbience(musicPath, file.path)
} else {
if (this.plugin.activeFile) {
this.pauseFileAmbience(this.plugin.activeFile)
}
if (
document
.getElementsByClassName('obsidian-app')[0]
.querySelector('.vault-ambience-player') == null
) {
if (this.plugin.settings.vault_ambience) this.plugin.playerPerGlobal.playAmbience()
}
}
})
}
playFileAmbience(musicPath, filePath) {
const container = document.getElementsByClassName('obsidian-app')[0]
if (!container) return
const player = document.createElement('div')
player.className = 'file-ambience-player'
if (isUrl(musicPath)) {
if (musicPath.includes('youtube.com') || musicPath.includes('youtu.be')) {
const videoId = extractId(musicPath)
const iframe = document.createElement('iframe')
iframe.src = `https://www.youtube.com/embed/${videoId}?autoplay=1&loop=1&playlist=${videoId}`
iframe.width = '300'
iframe.height = '166'
iframe.allow = 'autoplay'
iframe.addClass(this.plugin.settings.debug_frames ? 'visible' : 'hidden-frame')
player.appendChild(iframe)
} else {
try {
const parsedUrl = new URL(musicPath)
const allowedHosts = ['soundcloud.com']
if (allowedHosts.includes(parsedUrl.host)) {
const embedUrl = getEmbedUrl(musicPath)
const iframe = document.createElement('iframe')
iframe.src = embedUrl
iframe.width = '300'
iframe.height = '166'
iframe.allow = 'autoplay'
iframe.addClass(this.plugin.settings.debug_frames ? 'visible' : 'hidden-frame')
player.appendChild(iframe)
}
} catch (e) {
console.error('Invalid URL:', musicPath, e)
}
}
} else {
const audio = document.createElement('audio')
audio.src = this.plugin.app.vault.adapter.getResourcePath(musicPath)
audio.controls = true
audio.autoplay = true
audio.loop = true
audio.volume = (this.plugin.settings.music_volume || 50) / 100
player.appendChild(audio)
audio.addEventListener('timeupdate', () => {
const playbackData = this.plugin.fileAmbiencePlayers.get(filePath)
if (playbackData != null) {
playbackData.position = audio.currentTime
}
})
}
fs.ensureFileSync(CONFIG.LOGS_FILE)
container.appendChild(player)
this.plugin.fileAmbiencePlayers.set(filePath, { player, position: 0 })
}
/**
* Searches for specified words in file contents.
* @param {string} filepath - The path of the file to search.
* @param {string[]} data - Words to search for.
* @returns {Promise<void>} A promise representing the search operation.
*/
search(filepath, data) {
return __async(this, null, function* () {
const buffer = yield fs.readFile(filepath, { encoding: 'utf-8' })
const stream = fs.createWriteStream(CONFIG.LOGS_FILE, { flags: 'a' })
const contents = buffer.split(os.EOL)
for (let i = 0; i < contents.length; i++) {
const line = contents[i].toUpperCase()
for (const target of data) {
if (line.includes(target)) {
this.LOGGER.raw(colors.green, `Found "${target}" in L#${i} of: `)
this.LOGGER.raw(colors.cyan, filepath)
stream.write(`Found "${target}" in L#${i} of:` + os.EOL)
stream.write(` ${filepath}` + os.EOL)
pauseFileAmbience(filePath) {
var _a, _b
const playbackData = this.plugin.fileAmbiencePlayers.get(filePath)
if (playbackData == null) return
const { player } = playbackData
if (player instanceof HTMLAudioElement) {
playbackData.position = player.currentTime
player.pause()
} else if (player instanceof HTMLIFrameElement) {
if (player.src.includes('youtube.com') || player.src.includes('youtu.be')) {
;(_a = player.contentWindow) == null
? void 0
: _a.postMessage(JSON.stringify({ event: 'command', func: 'pauseVideo', args: [] }), '*')
} else {
try {
const parsedUrl = new URL(player.src)
const allowedHosts = ['soundcloud.com']
if (allowedHosts.includes(parsedUrl.host)) {
;(_b = player.contentWindow) == null
? void 0
: _b.postMessage(JSON.stringify({ method: 'pause' }), '*')
}
} catch (e) {
console.error('Invalid URL in player.src:', player.src, e)
}
}
stream.end()
})
}
/**
* Traverses directories and searches files for specified words.
* @param {string} directory - The directory to start traversal from.
* @returns {Promise<void>} A promise representing the traversal operation.
*/
traverse() {
return __async(this, arguments, function* (directory = __dirname) {
try {
const items = yield fs.readdir(directory)
for (const item of items) {
const itempath = path.join(directory, item)
const itemstats = yield fs.stat(itempath)
if (itemstats.isDirectory()) {
if (!this.EXCLUDING_FOLDERS.includes(item)) {
yield this.traverse(itempath)
}
} else if (itemstats.isFile()) {
yield this.search(itempath, this.EXCLUDING_VALUES)
} else {
continue
}
}
} catch (err) {
this.LOGGER.error(err)
}
})
}
}
var CONFIG = {
USE_GITIGNORE: true,
GITIGNORE_PATH: './.gitignore',
LOGS_FILE: `preparations-${/* @__PURE__ */ new Date().toLocaleDateString()}.logs`
}
var ask = (rl, question) =>
__async(null, null, function* () {
return yield new Promise((resolve) => {
rl.question(question, resolve)
})
})
void (() =>
__async(null, null, function* () {
const RL = readline.createInterface({
input: process.stdin,
output: process.stdout
})
try {
const finder = new LOCALE_MODULE()
const mode = yield ask(RL, colors.bgBlue(colors.yellow('Add custom entries (Y/N/IGNORE): ')))
if (mode.toUpperCase() === 'Y') {
const params = yield ask(RL, 'Enter parameters (comma-separated): ')
const diction = params.split(',').map((str) => str.trim())
finder.update(diction, mode.toUpperCase())
yield finder.traverse()
} else if (mode.toUpperCase() === 'N') {
yield finder.traverse()
}
} catch (error) {
console.error(
colors.red(typeof error === 'object' ? JSON.stringify(error, null, 2) : String(error))
)
} finally {
RL.close()
}
}))()
player.remove()
this.plugin.fileAmbiencePlayers.delete(filePath)
}
pauseVaultAmbience() {
this.plugin.unapply()
}
}
// source/player/factory/player_factory.ts
var PlayerFactory = class {
constructor(_plugin) {
__publicField(this, 'plugin')
this.plugin = _plugin
}
createPlayer(player, type) {
switch (type) {
case 'youtube':
case 'yt':
case 'ytube':
this.setupYouTubePlayer(player)
break
case 'sc':
case 'scloud':
case 'soundcloud':
this.setupSoundCloudPlayer(player)
break
case 'local':
case 'audio':
case 'fs':
this.setupLocalAudioPlayer(player)
break
default:
throw new Error('Got an unknown type of player!')
}
}
setupYouTubePlayer(player) {
const videoId = extractId(this.plugin.settings.vault_ambience_path)
const iframe = document.createElement('iframe')
iframe.src = `https://www.youtube.com/embed/${videoId}?autoplay=1&loop=1&enablejsapi=1&playlist=${videoId}`
iframe.width = '300'
iframe.height = '166'
iframe.allow = 'autoplay'
iframe.allowFullscreen = true
iframe.onload = () => {
var _a
const volume = this.plugin.settings.music_volume || 50
;(_a = iframe.contentWindow) == null
? void 0
: _a.postMessage(
JSON.stringify({
event: 'command',
func: 'setVolume',
args: [volume]
}),
'*'
)
}
player.appendChild(iframe)
}
setupSoundCloudPlayer(player) {
const embedUrl = getEmbedUrl(this.plugin.settings.vault_ambience_path)
const apiEnabledUrl = `${embedUrl}&visual=true&show_artwork=false`
const iframe = document.createElement('iframe')
iframe.src = apiEnabledUrl
iframe.width = '300'
iframe.height = '166'
iframe.allow = 'autoplay'
player.appendChild(iframe)
}
setupLocalAudioPlayer(player) {
const audio = document.createElement('audio')
audio.src = this.plugin.app.vault.adapter.getResourcePath(
this.plugin.settings.vault_ambience_path
)
audio.controls = true
audio.autoplay = true
audio.loop = true
audio.volume = (this.plugin.settings.music_volume || 50) / 100
audio.addEventListener('volumechange', () => {
this.plugin.settings.music_volume = audio.volume * 100
this.plugin.saveData(this.plugin.settings)
})
player.appendChild(audio)
}
}
// source/player/player_per_global.ts
var PlayerPerGlobal = class {
constructor(_plugin) {
__publicField(this, 'plugin')
this.plugin = _plugin
}
playAmbience() {
const container = document.getElementsByClassName('obsidian-app')[0]
if (!container) return
let player = container.querySelector('.vault-ambience-player')
if (player != null) player.remove()
if (container.getElementsByClassName('file-ambience-player').length > 0) return
player = document.createElement('div')
player.className = 'vault-ambience-player'
player.addClass(this.plugin.settings.debug_frames ? 'visible' : 'hidden-frame')
if (isUrl(this.plugin.settings.vault_ambience_path)) {
const url = new URL(this.plugin.settings.vault_ambience_path)
const allowedYouTubeHosts = ['youtube.com', 'youtu.be']
const allowedSoundCloudHosts = ['soundcloud.com']
const playerFactory = new PlayerFactory(this.plugin)
if (allowedYouTubeHosts.includes(url.host)) {
playerFactory.createPlayer(player, 'yt')
} else if (allowedSoundCloudHosts.includes(url.host)) {
playerFactory.createPlayer(player, 'sc')
}
playerFactory.createPlayer(player, 'local')
}
container.appendChild(player)
this.plugin.players.push(player)
}
}
// source/main.ts
var Whisperer = class _Whisperer extends import_obsidian3.Plugin {
constructor() {
super(...arguments)
__publicField(this, '_settings', DEFAULT_SETTINGS)
__publicField(this, 'players', [])
__publicField(this, 'fileAmbiencePlayers', /* @__PURE__ */ new Map())
__publicField(this, 'activeFile', null)
__publicField(this, 'playerPerFile', new PlayerPerFile(this))
__publicField(this, 'playerPerGlobal', new PlayerPerGlobal(this))
}
get settings() {
return this._settings
}
onload() {
return __async(this, null, function* () {
yield __superGet(_Whisperer.prototype, this, 'onload').call(this)
yield this.loadSettings()
this.addSettingTab(new WhispererSettingsTab(this.app, this))
this.registerEvent(
this.app.workspace.on('file-open', this.playerPerFile.handleFileOpen.bind(this))
)
const container = document.getElementsByClassName('obsidian-app')[0]
const script = document.createElement('script')
script.src = 'https://w.soundcloud.com/player/api.js'
script.id = 'whisperer-md-sc-widget'
container.appendChild(script)
this.apply()
})
}
onunload() {
return __async(this, null, function* () {
__superGet(_Whisperer.prototype, this, 'onunload').call(this)
const script = document.getElementById('whisperer-md-sc-widget')
if (script != null) {
script.src = ''
script.remove()
}
this.unapply()
})
}
loadSettings() {
return __async(this, null, function* () {
this._settings = Object.assign({}, DEFAULT_SETTINGS, yield this.loadData())
})
}
updateSettings(settings) {
return __async(this, null, function* () {
this.unapply()
this._settings = settings
yield this.saveData(this.settings)
this.apply()
})
}
updateVolumeSettings(settings) {
return __async(this, null, function* () {
if (this.players.length === 0) {
yield this.updateSettings(settings)
} else {
this._settings = settings
yield this.saveData(this.settings)
this.players.forEach((player) => {
var _a, _b
if (player.tagName === 'IFRAME') {
const iframe = player
try {
const url = new URL(iframe.src)
const allowedYouTubeHosts = ['youtube.com', 'www.youtube.com', 'youtu.be']
if (allowedYouTubeHosts.includes(url.hostname)) {
;(_a = iframe.contentWindow) == null
? void 0
: _a.postMessage(
JSON.stringify({
event: 'command',
func: 'setVolume',
args: [this.settings.music_volume]
}),
'*'
)
}
} catch (e) {
console.error('Invalid iframe src URL:', iframe.src, e)
}
try {
const url = new URL(iframe.src)
const allowedSoundCloudHosts = ['soundcloud.com', 'w.soundcloud.com']
if (allowedSoundCloudHosts.includes(url.hostname)) {
;(_b = iframe.contentWindow) == null
? void 0
: _b.postMessage(
JSON.stringify({
method: 'setVolume',
value: this.settings.music_volume / 100
}),
'https://w.soundcloud.com'
)
}
} catch (e) {
console.error('Invalid iframe src URL:', iframe.src, e)
}
} else if (player.tagName === 'AUDIO') {
const audio = player
audio.volume = (this.settings.music_volume | 50) / 100
}
})
}
})
}
updateVisibleSettings(settings) {
return __async(this, null, function* () {
if (this.players.length === 0) {
yield this.updateSettings(settings)
} else {
this._settings = settings
yield this.saveData(this.settings)
this.players.forEach((player) => {
player.removeClass(this.settings.debug_frames ? 'hidden-frame' : 'visible')
player.addClass(this.settings.debug_frames ? 'visible' : 'hidden-frame')
})
}
})
}
unapply() {
this.players.forEach((player) => {
player.remove()
const iframe = player.querySelector('iframe')
if (iframe != null) {
iframe.src = ''
}
})
this.players = []
for (const playerData of this.fileAmbiencePlayers.values()) {
playerData.player.remove()
}
}
apply() {
if (this.settings.vault_ambience) this.playerPerGlobal.playAmbience()
}
}
//! Volume adjustment is not directly supported by SoundCloud embedded players.

12
jest.config.mjs Normal file
View file

@ -0,0 +1,12 @@
// jest.config.mjs
/** @type {import('jest').Config} */
export default {
preset: 'ts-jest',
testEnvironment: 'jsdom', // requires `npm install --save-dev jest-environment-jsdom`
moduleNameMapper: {
'^obsidian$': '<rootDir>/__mocks__/obsidian.ts',
'^source/(.*)$': '<rootDir>/source/$1'
},
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
extensionsToTreatAsEsm: ['.ts'] // if you need to treat .ts as ESM :contentReference[oaicite:3]{index=3}
}

17
jest.setup.js Normal file
View file

@ -0,0 +1,17 @@
HTMLMediaElement.prototype.pause = function () {
/* no-op */
}
HTMLMediaElement.prototype.play = function () {
/* no-op */
}
HTMLElement.prototype.empty = function () {
while (this.firstChild) this.removeChild(this.firstChild)
}
HTMLElement.prototype.addClass = function (c) {
this.classList.add(c)
}
HTMLElement.prototype.removeClass = function (c) {
this.classList.remove(c)
}

BIN
package-lock.json generated

Binary file not shown.

View file

@ -39,9 +39,9 @@
"prepare:python-root": "python3 scripts/python/clonevenv.py",
"prepare:template": "ts-node index.ts",
"update-analytics": "ts-node scripts/js/analytics-workflow.ts",
"test": "mocha --timeout 10000 --import=tsx test/**/*.test.ts --reporter mocha-github-actions-reporter --exit",
"test": "jest --coverage",
"buildtsc": "tsc",
"build": "tsc -noEmit -skipLibCheck source/main.ts && node esbuild.config.mjs production",
"build": "tsc -noEmit -skipLibCheck --downlevelIteration source/main.ts && node esbuild.config.mjs production",
"pack": "python scripts/python/pack.py",
"release-tags": "cd scripts/python && python update_versions_table.py",
"release-utilities": "cd scripts/js && ts-node utilities.ts",
@ -235,6 +235,7 @@
"husky": "^9.1.7",
"imagemin-lint-staged": "^0.5.1",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jscpd": "^4.0.5",
"jsdom": "^26.1.0",
"lint": "^1.1.2",
@ -279,14 +280,16 @@
"ignore": [
"**/out/",
"**/lib/*",
"**/venv/"
"**/venv/",
"**/jest.setup.js"
]
},
"ts-standard": {
"ignore": [
"**/out/",
"**/lib/*",
"**/venv/"
"**/venv/",
"**/jest.setup.ts"
]
}
}

46
test/functions.test.ts Normal file
View file

@ -0,0 +1,46 @@
import { isUrl, getEmbedUrl, extractId } from 'source/utils/functions'
describe('Utils functions', () => {
describe('isUrl()', () => {
it('returns true for http(s) URLs', () => {
expect(isUrl('https://example.com')).toBe(true)
expect(isUrl('http://foo.bar')).toBe(true)
})
it('returns false otherwise', () => {
expect(isUrl('ftp://example.com')).toBe(false)
expect(isUrl('')).toBe(false)
})
})
describe('getEmbedUrl()', () => {
it('embeds youtube.com URLs', () => {
expect(getEmbedUrl('https://www.youtube.com/watch?v=ABCDEFGHIJK'))
.toBe('https://www.youtube.com/embed/ABCDEFGHIJK?autoplay=1&start=0')
})
it('embeds youtu.be URLs', () => {
expect(getEmbedUrl('https://youtu.be/ABCDEFGHIJK'))
.toBe('https://www.youtube.com/embed/ABCDEFGHIJK?autoplay=1&start=0')
})
it('embeds soundcloud URLs', () => {
const url = 'https://soundcloud.com/artist/track'
const out = getEmbedUrl(url)
expect(out).toContain('w.soundcloud.com/player')
expect(out).toContain(encodeURIComponent(url))
})
it('throws on unsupported URLs', () => {
expect(() => getEmbedUrl('https://vimeo.com/123')).toThrow('Unsupported URL type.')
})
})
describe('extractId()', () => {
it('extracts from youtube.com', () => {
expect(extractId('https://www.youtube.com/watch?v=ABCDEFGHIJK')).toBe('ABCDEFGHIJK')
})
it('extracts from youtu.be', () => {
expect(extractId('https://youtu.be/ABCDEFGHIJK')).toBe('ABCDEFGHIJK')
})
it('returns empty string when no ID present', () => {
expect(extractId('https://example.com')).toBe('')
})
})
})

View file

@ -1,28 +0,0 @@
import { expect } from 'chai'
import { LOCALE_LOGGER } from 'index'
import sinon from 'sinon'
import colors from 'colors/safe'
describe('LOCALE_LOGGER', () => {
let logger: LOCALE_LOGGER
let consoleStub: sinon.SinonStub
beforeEach(() => {
logger = new LOCALE_LOGGER()
consoleStub = sinon.stub(console, 'info')
})
afterEach(() => {
consoleStub.restore()
})
it('should format info messages correctly', () => {
logger.info('Test message', 123, { key: 'value' }, {})
const output = consoleStub.firstCall.args[0]
expect(output).to.include('Test message')
expect(output).to.include('123')
expect(output).to.include(colors.blue(''))
expect(output).to.include('{}')
})
})

View file

@ -1,29 +0,0 @@
import { expect } from 'chai'
import LOCALE_MODULE from 'index'
import mock from 'mock-fs'
import fs from 'fs-extra'
describe('LOCALE_MODULE', () => {
let module: LOCALE_MODULE
beforeEach(() => {
mock({
'test-dir': {
'file1.txt': 'PATTERNU should be found',
subdir: {
'file2.ts': '// PATTERNU in comment'
},
'.gitignore': 'subdir\n'
}
})
module = new LOCALE_MODULE('test-dir/test-logs.log')
module.ROOT_DIRECTORY = 'test-dir'
})
it('should find excluded patterns', async () => {
await module.traverse()
const logContent = fs.readFileSync(module.CONFIG.LOGS_FILE, 'utf-8')
expect(logContent).to.include('PATTERNU')
})
})

View file

@ -0,0 +1,46 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable no-undef */
/* eslint-env browser, jest */
/* eslint-disable @typescript-eslint/no-unused-expressions */
import PlayerFactory from 'source/player/factory/player_factory'
import Whisperer from 'source/main'
describe('Factory PlayerFactory', () => {
let plugin: Whisperer, container: HTMLElement
let factory: PlayerFactory
beforeEach(() => {
container = document.createElement('div')
plugin = new Whisperer({} as any, {} as any)
plugin.settings.vault_ambience_path = 'https://youtu.be/ABCDEF12345'
plugin.settings.music_volume = 30
// stub local resource path
plugin.app = { vault: { adapter: { getResourcePath: (p: string) => '/res/' + p } } } as any
factory = new PlayerFactory(plugin)
})
it('creates YouTube iframe', () => {
factory.createPlayer(container, 'youtube')
const iframe = container.querySelector('iframe')!
expect(iframe.src).toContain('embed/ABCDEF12345')
expect(iframe.allow).toBe('autoplay')
})
it('creates SoundCloud iframe', () => {
plugin.settings.vault_ambience_path = 'https://soundcloud.com/x/y'
factory.createPlayer(container, 'sc')
const iframe = container.querySelector('iframe')!
expect(iframe.src).toContain('w.soundcloud.com/player')
})
it('creates local audio element', () => {
factory.createPlayer(container, 'local')
const audio = container.querySelector('audio')!
expect(audio.src).toContain('/res/' + plugin.settings.vault_ambience_path)
expect(audio.volume).toBeCloseTo(0.3)
})
it('throws on unknown type', () => {
expect(() => factory.createPlayer(container, 'unknown')).toThrow()
})
})

View file

@ -0,0 +1,57 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable no-undef */
/* eslint-env browser, jest */
/* eslint-disable @typescript-eslint/no-unused-expressions */
import PlayerPerFile from 'source/player/player_per_file'
import Whisperer from 'source/main'
import { TFile } from 'obsidian'
describe('PlayerPerFile', () => {
let plugin: Whisperer, handler: PlayerPerFile
beforeEach(() => {
document.body.innerHTML = '<div class="obsidian-app"></div>'
const fakeApp = {
metadataCache: {
getFileCache: jest.fn().mockReturnValue({
frontmatter: { music: 'https://youtu.be/XXX111YYY22' }
})
},
vault: {
adapter: { getResourcePath: (p: string) => 'r/' + p }
},
workspace: { on: jest.fn() }
} as any
plugin = new Whisperer(fakeApp, {} as any)
plugin.settings.ambience_per_files = true
handler = new PlayerPerFile(plugin)
})
it('plays a new file ambience', async () => {
await handler.handleFileOpen({ path: 'file1.md' } as TFile)
expect(plugin.activeFile).toBe('file1.md')
expect(document.querySelector('.file-ambience-player')).toBeTruthy()
})
it('pauses previous and vault ambience', async () => {
plugin.activeFile = 'old.md'
plugin.fileAmbiencePlayers.set('old.md', {
player: document.createElement('audio'),
position: 0
})
plugin.settings.vault_ambience = true
const spy = jest.spyOn(plugin, 'unapply')
await handler.handleFileOpen({ path: 'new.md' } as TFile)
expect(spy).toHaveBeenCalled()
expect(plugin.fileAmbiencePlayers.has('old.md')).toBe(false)
})
it('does nothing when ambience_per_files = false', async () => {
plugin.settings.ambience_per_files = false
const spy = jest.spyOn(handler, 'playFileAmbience')
await handler.handleFileOpen({ path: 'x.md' } as TFile)
expect(spy).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,33 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable no-undef */
/* eslint-env browser, jest */
/* eslint-disable @typescript-eslint/no-unused-expressions */
import PlayerPerGlobal from 'source/player/player_per_global'
import Whisperer from 'source/main'
describe('PlayerPerGlobal', () => {
let plugin: Whisperer
beforeEach(() => {
document.body.innerHTML = '<div class="obsidian-app"></div>'
plugin = new Whisperer({} as any, {} as any)
plugin.settings.vault_ambience = true
plugin.settings.vault_ambience_path = 'https://youtu.be/ZZZZZ123456'
// stub local fallback
plugin.app.vault = { adapter: { getResourcePath: (p: string) => '/m/' + p } } as any
})
it('appends vault ambience player', () => {
new PlayerPerGlobal(plugin).playAmbience()
const nodes = document.querySelectorAll('.vault-ambience-player')
expect(nodes.length).toBe(1)
expect(plugin.players).toContainEqual(nodes[0] as HTMLElement)
})
it('does not duplicate when file ambience exists', () => {
document.querySelector('.obsidian-app')!.innerHTML =
'<div class="file-ambience-player"></div>'
new PlayerPerGlobal(plugin).playAmbience()
expect(document.querySelectorAll('.vault-ambience-player').length).toBe(0)
})
})

27
test/settings_tab.test.ts Normal file
View file

@ -0,0 +1,27 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable no-undef */
/* eslint-env browser, jest */
/* eslint-disable @typescript-eslint/no-unused-expressions */
import WhispererSettingsTab from 'source/settings'
import Whisperer from 'source/main'
import { App } from 'obsidian'
describe('Settings Tab', () => {
let plugin: Whisperer, tab: WhispererSettingsTab, container: HTMLElement
beforeEach(() => {
container = document.createElement('div')
container.empty() // from jest.setup.js
plugin = new Whisperer({} as any, {} as any)
tab = new WhispererSettingsTab({} as App, plugin);
// override the tab's containerEl
(tab as any).containerEl = container
})
it('renders at least one setting item', () => {
tab.display()
const items = container.querySelectorAll('.setting-item')
expect(items.length).toBeGreaterThan(0)
})
})

View file

@ -18,5 +18,5 @@
"sourceMap": false
},
"exclude": ["node_modules", "out", "git", "venv"],
"include": ["*.ts", "**/*.ts"]
"include": ["*.ts", "**/*.ts", "jest.setup.ts"]
}

22
typings/index.d.ts vendored
View file

@ -28,32 +28,32 @@ declare module '@patternu/preparation' {
* Log informational message
* @param data Flexible arguments to log
*/
info(...data: unknown[]): void
info (...data: unknown[]): void
/**
* Log warning message
* @param data Flexible arguments to log
*/
warn(...data: unknown[]): void
warn (...data: unknown[]): void
/**
* Log error message
* @param data Flexible arguments to log
*/
error(...data: unknown[]): void
error (...data: unknown[]): void
/**
* Log success message
* @param data Flexible arguments to log
*/
success(...data: unknown[]): void
success (...data: unknown[]): void
/**
* Raw formatted log output
* @param color Colorization function
* @param data Flexible arguments to log
*/
raw(color: (str: string) => string, ...data: unknown[]): void
raw (color: (str: string) => string, ...data: unknown[]): void
/**
* Create colored message string
@ -61,7 +61,7 @@ declare module '@patternu/preparation' {
* @param message Message to format
* @returns Colored message string
*/
msg(color: (str: string) => string, message: string): string
msg (color: (str: string) => string, message: string): string
}
/**
@ -72,7 +72,7 @@ declare module '@patternu/preparation' {
public ROOT_DIRECTORY: string
/** Logger instance */
public readonly LOGGER: LOCALE_LOGGER
private EXCLUDING_FOLDERS: string[]
private readonly EXCLUDING_FOLDERS: string[]
private readonly EXCLUDING_VALUES: string[]
/**
@ -80,20 +80,20 @@ declare module '@patternu/preparation' {
* @param entries Entries to add to exclusion lists
* @param actions Update action type (Y/N)
*/
public update(entries: string[], actions: string): void
public update (entries: string[], actions: string): void
/**
* Search file contents for target strings
* @param filepath Path to search
* @param data Target strings to find
*/
public search(filepath: string, data: string[]): Promise<void>
public search (filepath: string, data: string[]): Promise<void>
/**
* Recursive directory traversal
* @param directory Starting directory
*/
public traverse(directory?: string): Promise<void>
public traverse (directory?: string): Promise<void>
}
/**
@ -102,5 +102,5 @@ declare module '@patternu/preparation' {
* @param question Prompt to display
* @returns Promise resolving to user input
*/
export function ask(rl: readline.Interface, question: string): Promise<string>
export function ask (rl: readline.Interface, question: string): Promise<string>
}