mirror of
https://github.com/oen/liquid-template.git
synced 2026-07-22 05:40:24 +00:00
Initial POC
POC for a plugin that allows you to write templates with LiquidJS tags
This commit is contained in:
parent
a90dc017a1
commit
b490f21770
7 changed files with 488 additions and 98 deletions
9
.editorconfig
Normal file
9
.editorconfig
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
root = true
|
||||
|
||||
[**.{ts,json,js}]
|
||||
end_of_line = crlf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
130
main.ts
130
main.ts
|
|
@ -1,112 +1,46 @@
|
|||
import { App, Modal, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian';
|
||||
import { Plugin } from "obsidian";
|
||||
|
||||
interface MyPluginSettings {
|
||||
mySetting: string;
|
||||
}
|
||||
import { LiquidTemplatesSettings, DEFAULT_SETTINGS, LiquidTemplatesSettingsTab } from "./settings";
|
||||
import TemplateSuggest from "./suggest/template-suggest";
|
||||
|
||||
const DEFAULT_SETTINGS: MyPluginSettings = {
|
||||
mySetting: 'default'
|
||||
}
|
||||
export default class LiquidTemplates extends Plugin {
|
||||
private autosuggest: TemplateSuggest;
|
||||
public settings: LiquidTemplatesSettings;
|
||||
|
||||
export default class MyPlugin extends Plugin {
|
||||
settings: MyPluginSettings;
|
||||
async onload(): Promise<void> {
|
||||
console.log('loading liquid templates plugin');
|
||||
await this.loadSettings();
|
||||
|
||||
async onload() {
|
||||
console.log('loading plugin');
|
||||
this.setupAutosuggest();
|
||||
|
||||
await this.loadSettings();
|
||||
this.addSettingTab(new LiquidTemplatesSettingsTab(this.app, this))
|
||||
}
|
||||
|
||||
this.addRibbonIcon('dice', 'Sample Plugin', () => {
|
||||
new Notice('This is a notice!');
|
||||
});
|
||||
onunload(): void {
|
||||
console.log('unloading liquid templates plugin');
|
||||
}
|
||||
|
||||
this.addStatusBarItem().setText('Status Bar Text');
|
||||
|
||||
this.addCommand({
|
||||
id: 'open-sample-modal',
|
||||
name: 'Open Sample Modal',
|
||||
// callback: () => {
|
||||
// console.log('Simple Callback');
|
||||
// },
|
||||
checkCallback: (checking: boolean) => {
|
||||
let leaf = this.app.workspace.activeLeaf;
|
||||
if (leaf) {
|
||||
if (!checking) {
|
||||
new SampleModal(this.app).open();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
this.addSettingTab(new SampleSettingTab(this.app, this));
|
||||
setupAutosuggest(): void {
|
||||
this.autosuggest = new TemplateSuggest(this.app, this);
|
||||
|
||||
this.registerCodeMirror((cm: CodeMirror.Editor) => {
|
||||
console.log('codemirror', cm);
|
||||
cm.on("change", this.autosuggestHandler);
|
||||
});
|
||||
}
|
||||
|
||||
this.registerDomEvent(document, 'click', (evt: MouseEvent) => {
|
||||
console.log('click', evt);
|
||||
});
|
||||
autosuggestHandler = (
|
||||
cmEditor: CodeMirror.Editor,
|
||||
changeObj: CodeMirror.EditorChange
|
||||
): boolean => {
|
||||
return this.autosuggest?.update(cmEditor, changeObj);
|
||||
};
|
||||
|
||||
this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000));
|
||||
}
|
||||
async loadSettings(): Promise<void> {
|
||||
this.settings = Object.assign(DEFAULT_SETTINGS, await this.loadData());
|
||||
}
|
||||
|
||||
onunload() {
|
||||
console.log('unloading plugin');
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
}
|
||||
|
||||
class SampleModal extends Modal {
|
||||
constructor(app: App) {
|
||||
super(app);
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
let {contentEl} = this;
|
||||
contentEl.setText('Woah!');
|
||||
}
|
||||
|
||||
onClose() {
|
||||
let {contentEl} = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
}
|
||||
|
||||
class SampleSettingTab extends PluginSettingTab {
|
||||
plugin: MyPlugin;
|
||||
|
||||
constructor(app: App, plugin: MyPlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
let {containerEl} = this;
|
||||
|
||||
containerEl.empty();
|
||||
|
||||
containerEl.createEl('h2', {text: 'Settings for my awesome plugin.'});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Setting #1')
|
||||
.setDesc('It\'s a secret')
|
||||
.addText(text => text
|
||||
.setPlaceholder('Enter your secret')
|
||||
.setValue('')
|
||||
.onChange(async (value) => {
|
||||
console.log('Secret: ' + value);
|
||||
this.plugin.settings.mySetting = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
}
|
||||
async saveSettings(): Promise<void> {
|
||||
this.setupAutosuggest();
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
87
settings.ts
Normal file
87
settings.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { App, PluginSettingTab, Setting } from "obsidian";
|
||||
import LiquidTemplates from "./main";
|
||||
|
||||
export interface LiquidTemplatesSettings {
|
||||
templatesFolder: string;
|
||||
dateFormat: string;
|
||||
timeFormat: string;
|
||||
autocompleteTrigger: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: LiquidTemplatesSettings = {
|
||||
templatesFolder: 'templates',
|
||||
dateFormat: '%Y-%m-%d',
|
||||
timeFormat: '%H:%M',
|
||||
autocompleteTrigger: ';;'
|
||||
};
|
||||
|
||||
export class LiquidTemplatesSettingsTab extends PluginSettingTab {
|
||||
plugin: LiquidTemplates;
|
||||
|
||||
constructor(app: App, plugin: LiquidTemplates) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
|
||||
containerEl.empty();
|
||||
|
||||
containerEl.createEl('h2', {
|
||||
text: 'Liquid Templates',
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Templates folder')
|
||||
.setDesc('Folder where to find your templates, it will be used also as root folder for the liquid tags, like the include ones')
|
||||
.addMomentFormat((text) =>
|
||||
text
|
||||
.setDefaultFormat(DEFAULT_SETTINGS.templatesFolder)
|
||||
.setValue(this.plugin.settings.templatesFolder)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.templatesFolder = value || DEFAULT_SETTINGS.templatesFolder;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Date format')
|
||||
.setDesc('Output format for render dates')
|
||||
.addMomentFormat((text) =>
|
||||
text
|
||||
.setDefaultFormat(DEFAULT_SETTINGS.dateFormat)
|
||||
.setValue(this.plugin.settings.dateFormat)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.dateFormat = value || DEFAULT_SETTINGS.dateFormat;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Time format')
|
||||
.setDesc('Output format for render time')
|
||||
.addMomentFormat((text) =>
|
||||
text
|
||||
.setDefaultFormat(DEFAULT_SETTINGS.timeFormat)
|
||||
.setValue(this.plugin.settings.timeFormat)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.timeFormat = value || DEFAULT_SETTINGS.timeFormat;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Autocomplete trigger')
|
||||
.setDesc('Character/s that trigger the autocomplete menu')
|
||||
.addMomentFormat((text) =>
|
||||
text
|
||||
.setPlaceholder(DEFAULT_SETTINGS.autocompleteTrigger)
|
||||
.setValue(this.plugin.settings.autocompleteTrigger || DEFAULT_SETTINGS.autocompleteTrigger)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.autocompleteTrigger = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
129
suggest/codemirror-suggest.ts
Normal file
129
suggest/codemirror-suggest.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { App, ISuggestOwner, Scope } from "obsidian";
|
||||
|
||||
import Suggest from "./suggest";
|
||||
|
||||
function checkForInputPhrase(
|
||||
cmEditor: CodeMirror.Editor,
|
||||
pos: CodeMirror.Position,
|
||||
phrase: string
|
||||
): boolean {
|
||||
const from = {
|
||||
line: pos.line,
|
||||
ch: pos.ch - phrase.length,
|
||||
};
|
||||
return cmEditor.getRange(from, pos) === phrase;
|
||||
}
|
||||
|
||||
function isCursorBeforePos(
|
||||
pos: CodeMirror.Position,
|
||||
cursor: CodeMirror.Position
|
||||
): boolean {
|
||||
if (pos.line === cursor.line) {
|
||||
return cursor.ch < pos.ch;
|
||||
}
|
||||
return cursor.line < pos.line;
|
||||
}
|
||||
|
||||
export default abstract class CodeMirrorSuggest<T> implements ISuggestOwner<T> {
|
||||
protected app: App;
|
||||
protected cmEditor: CodeMirror.Editor;
|
||||
private scope: Scope;
|
||||
|
||||
private suggestEl: HTMLElement;
|
||||
private instructionsEl: HTMLElement;
|
||||
private suggest: Suggest<T>;
|
||||
|
||||
private startPos: CodeMirror.Position;
|
||||
private triggerPhrase: string;
|
||||
|
||||
constructor(app: App, triggerPhrase: string) {
|
||||
this.triggerPhrase = triggerPhrase;
|
||||
this.app = app;
|
||||
this.scope = new Scope();
|
||||
|
||||
this.suggestEl = createDiv("suggestion-container");
|
||||
const suggestion = this.suggestEl.createDiv("suggestion");
|
||||
this.instructionsEl = this.suggestEl.createDiv("prompt-instructions");
|
||||
this.suggest = new Suggest(this, suggestion, this.scope);
|
||||
|
||||
this.scope.register([], "Escape", this.close.bind(this));
|
||||
}
|
||||
|
||||
public setInstructions(
|
||||
createInstructionsFn: (containerEl: HTMLElement) => void
|
||||
): void {
|
||||
this.instructionsEl.empty();
|
||||
createInstructionsFn(this.instructionsEl);
|
||||
}
|
||||
|
||||
public update(
|
||||
cmEditor: CodeMirror.Editor,
|
||||
changeObj: CodeMirror.EditorChange
|
||||
): boolean {
|
||||
if (this.cmEditor !== cmEditor) {
|
||||
this.suggestEl?.detach();
|
||||
}
|
||||
this.cmEditor = cmEditor;
|
||||
const cursorPos = cmEditor.getCursor();
|
||||
|
||||
// autosuggest is open
|
||||
if (this.suggestEl.parentNode) {
|
||||
if (isCursorBeforePos(this.startPos, cursorPos)) {
|
||||
this.close();
|
||||
return false;
|
||||
}
|
||||
this.attachAtCursor();
|
||||
} else {
|
||||
if (
|
||||
changeObj.text.length === 1 && // ignore multi-cursors
|
||||
checkForInputPhrase(this.cmEditor, cursorPos, this.triggerPhrase) &&
|
||||
!document.querySelector(".suggestion-container") // don't trigger multiple autosuggests
|
||||
) {
|
||||
this.startPos = cursorPos;
|
||||
this.open();
|
||||
this.attachAtCursor();
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected getStartPos(): CodeMirror.Position {
|
||||
return {
|
||||
line: this.startPos.line,
|
||||
ch: this.startPos.ch - this.triggerPhrase.length,
|
||||
};
|
||||
}
|
||||
|
||||
protected getInputStr(): string {
|
||||
// return string from / to cursor
|
||||
const cursor = this.cmEditor.getCursor();
|
||||
const line = this.cmEditor.getLine(cursor.line);
|
||||
return line.substring(this.startPos.ch, cursor.ch);
|
||||
}
|
||||
|
||||
private attachAtCursor() {
|
||||
const inputStr = this.getInputStr();
|
||||
const suggestions = this.getSuggestions(inputStr);
|
||||
this.suggest.setSuggestions(suggestions);
|
||||
|
||||
this.cmEditor.addWidget(this.cmEditor.getCursor(), this.suggestEl, true);
|
||||
}
|
||||
|
||||
open(): void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(<any>this.app).keymap.pushScope(this.scope);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(<any>this.app).keymap.popScope(this.scope);
|
||||
this.startPos = null;
|
||||
this.suggest.setSuggestions([]);
|
||||
this.suggestEl.detach();
|
||||
}
|
||||
|
||||
abstract getSuggestions(inputStr: string): T[];
|
||||
abstract renderSuggestion(item: T, el: HTMLElement): void;
|
||||
abstract selectSuggestion(item: T, evt: MouseEvent | KeyboardEvent): void;
|
||||
}
|
||||
102
suggest/suggest.ts
Normal file
102
suggest/suggest.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { ISuggestOwner, Scope } from "obsidian";
|
||||
|
||||
export const wrapAround = (value: number, size: number): number => {
|
||||
return ((value % size) + size) % size;
|
||||
};
|
||||
|
||||
export default class Suggest<T> {
|
||||
private owner: ISuggestOwner<T>;
|
||||
private values: T[];
|
||||
private suggestions: HTMLDivElement[];
|
||||
private selectedItem: number;
|
||||
private containerEl: HTMLElement;
|
||||
|
||||
constructor(owner: ISuggestOwner<T>, containerEl: HTMLElement, scope: Scope) {
|
||||
this.owner = owner;
|
||||
this.containerEl = containerEl;
|
||||
|
||||
containerEl.on(
|
||||
"click",
|
||||
".suggestion-item",
|
||||
this.onSuggestionClick.bind(this)
|
||||
);
|
||||
containerEl.on(
|
||||
"mousemove",
|
||||
".suggestion-item",
|
||||
this.onSuggestionMouseover.bind(this)
|
||||
);
|
||||
|
||||
scope.register([], "ArrowUp", (event) => {
|
||||
if (!event.isComposing) {
|
||||
this.setSelectedItem(this.selectedItem - 1, true);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
scope.register([], "ArrowDown", (event) => {
|
||||
if (!event.isComposing) {
|
||||
this.setSelectedItem(this.selectedItem + 1, true);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const selectItem = (event: KeyboardEvent) => {
|
||||
if (!event.isComposing) {
|
||||
this.useSelectedItem(event);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
scope.register([], "Enter", selectItem);
|
||||
scope.register(["Shift"], "Enter", selectItem);
|
||||
}
|
||||
|
||||
onSuggestionClick(event: MouseEvent, el: HTMLDivElement): void {
|
||||
event.preventDefault();
|
||||
|
||||
const item = this.suggestions.indexOf(el);
|
||||
this.setSelectedItem(item, false);
|
||||
this.useSelectedItem(event);
|
||||
}
|
||||
|
||||
onSuggestionMouseover(_event: MouseEvent, el: HTMLDivElement): void {
|
||||
const item = this.suggestions.indexOf(el);
|
||||
this.setSelectedItem(item, false);
|
||||
}
|
||||
|
||||
setSuggestions(values: T[]): void {
|
||||
this.containerEl.empty();
|
||||
const suggestionEls: HTMLDivElement[] = [];
|
||||
|
||||
values.forEach((value) => {
|
||||
const suggestionEl = this.containerEl.createDiv("suggestion-item");
|
||||
this.owner.renderSuggestion(value, suggestionEl);
|
||||
suggestionEls.push(suggestionEl);
|
||||
});
|
||||
|
||||
this.values = values;
|
||||
this.suggestions = suggestionEls;
|
||||
this.setSelectedItem(0, false);
|
||||
}
|
||||
|
||||
useSelectedItem(event: MouseEvent | KeyboardEvent): void {
|
||||
const currentValue = this.values[this.selectedItem];
|
||||
if (currentValue) {
|
||||
this.owner.selectSuggestion(currentValue, event);
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedItem(selectedIndex: number, scrollIntoView: boolean): void {
|
||||
const normalizedIndex = wrapAround(selectedIndex, this.suggestions.length);
|
||||
const prevSelectedSuggestion = this.suggestions[this.selectedItem];
|
||||
const selectedSuggestion = this.suggestions[normalizedIndex];
|
||||
|
||||
prevSelectedSuggestion?.removeClass("is-selected");
|
||||
selectedSuggestion?.addClass("is-selected");
|
||||
|
||||
this.selectedItem = normalizedIndex;
|
||||
|
||||
if (scrollIntoView) {
|
||||
selectedSuggestion.scrollIntoView(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
108
suggest/template-suggest.ts
Normal file
108
suggest/template-suggest.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { App, TFile } from "obsidian";
|
||||
import { Liquid } from "liquidjs";
|
||||
import type PoweredTemplates from "../main";
|
||||
import CodeMirrorSuggest from "./codemirror-suggest";
|
||||
import { getTFilesFromFolder } from "utils";
|
||||
|
||||
interface ITemplateCompletion {
|
||||
label: string;
|
||||
file?: TFile;
|
||||
}
|
||||
|
||||
// TODO: create a class for the suggestion that implements the ITemplateCompletion and have some
|
||||
// utility method to render the template
|
||||
// TODO: generate a proper context and use the defined settings for the date and time format
|
||||
export default class TemplateSuggest extends CodeMirrorSuggest<ITemplateCompletion> {
|
||||
plugin: PoweredTemplates;
|
||||
engine: Liquid;
|
||||
|
||||
constructor(app: App, plugin: PoweredTemplates) {
|
||||
super(app, plugin.settings.autocompleteTrigger);
|
||||
|
||||
this.plugin = plugin;
|
||||
// TODO: move me in a separate class
|
||||
this.engine = new Liquid({
|
||||
fs: {
|
||||
readFileSync: (file) => {
|
||||
// TODO: implement me
|
||||
return "";
|
||||
},
|
||||
readFile: async (file) => {
|
||||
const { templatesFolder } = this.plugin.settings;
|
||||
// TODO: find a better way to do this
|
||||
const fileObj = getTFilesFromFolder(app, templatesFolder).find(f => f.basename === file);
|
||||
return app.vault.read(fileObj);
|
||||
},
|
||||
existsSync: () => {
|
||||
return true
|
||||
},
|
||||
exists: async () => {
|
||||
return true
|
||||
},
|
||||
resolve: (_root, file, _ext) => {
|
||||
return file
|
||||
}
|
||||
},
|
||||
extname: '.md',
|
||||
greedy: true,
|
||||
});
|
||||
}
|
||||
|
||||
open(): void {
|
||||
super.open();
|
||||
}
|
||||
|
||||
|
||||
getSuggestions(inputStr: string): ITemplateCompletion[] {
|
||||
// handle no matches
|
||||
const suggestions = this.getTemplateSuggestions(inputStr);
|
||||
if (suggestions.length) {
|
||||
return suggestions;
|
||||
} else {
|
||||
return [{ label: inputStr }];
|
||||
}
|
||||
}
|
||||
|
||||
getTemplateSuggestions(inputStr: string): ITemplateCompletion[] {
|
||||
// find the list of files
|
||||
// TODO: filter before returning all the files
|
||||
const templates = getTFilesFromFolder(this.app, this.plugin.settings.templatesFolder);
|
||||
return templates
|
||||
.map(file => ({ label: file.basename, file: file }))
|
||||
.filter((items) => items.label.toLowerCase().startsWith(inputStr));
|
||||
}
|
||||
|
||||
renderSuggestion(suggestion: ITemplateCompletion, el: HTMLElement): void {
|
||||
el.setText(suggestion.label);
|
||||
}
|
||||
|
||||
async generateContext() {
|
||||
return {
|
||||
// TODO: improve me
|
||||
date: await this.engine.parseAndRender(`{{ "now" | date: "${this.plugin.settings.dateFormat}"}}`),
|
||||
time: await this.engine.parseAndRender(`{{ "now" | date: "${this.plugin.settings.timeFormat}"}}`),
|
||||
title: await this.app.workspace.getActiveFile().basename,
|
||||
default_date_format: this.plugin.settings.dateFormat,
|
||||
default_time_format: this.plugin.settings.timeFormat
|
||||
}
|
||||
}
|
||||
|
||||
async selectSuggestion(
|
||||
suggestion: ITemplateCompletion,
|
||||
_event: KeyboardEvent | MouseEvent
|
||||
): Promise<void> {
|
||||
const head = this.getStartPos();
|
||||
const anchor = this.cmEditor.getCursor();
|
||||
|
||||
if (suggestion.file) {
|
||||
const templateString = await this.app.vault.read(suggestion.file)
|
||||
|
||||
const rendered = await this.engine.parseAndRender(
|
||||
templateString,
|
||||
await this.generateContext()
|
||||
);
|
||||
this.cmEditor.replaceRange(rendered.trim(), head, anchor);
|
||||
}
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
21
utils.ts
Normal file
21
utils.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { App, normalizePath, TAbstractFile, TFile, TFolder, Vault } from "obsidian";
|
||||
|
||||
export function getTFilesFromFolder(app: App, folderName: string): Array<TFile> {
|
||||
folderName = normalizePath(folderName);
|
||||
let folder = app.vault.getAbstractFileByPath(folderName);
|
||||
if (!folder) throw new Error(`${folderName} folder doesn't exist`);
|
||||
if (!(folder instanceof TFolder)) throw new Error(`${folderName} is a file, not a folder`);
|
||||
|
||||
let files: Array<TFile> = [];
|
||||
Vault.recurseChildren(folder, (file: TAbstractFile) => {
|
||||
if (file instanceof TFile) {
|
||||
files.push(file);
|
||||
}
|
||||
});
|
||||
|
||||
files.sort((a, b) => {
|
||||
return a.basename.localeCompare(b.basename);
|
||||
});
|
||||
|
||||
return files;
|
||||
}
|
||||
Loading…
Reference in a new issue