Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGE.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
# Next release

- The web Document renderer now supports a `pageTurnBehavior` option with three
modes: 'log' (default, removes options but keeps narrative), 'remove' (removes
the entire prior frame), and 'fade' (fades out and removes the prior frame,
which requires a CSS transition on the frame element).
- The `end` handler hook is now called after the final display, allowing handlers
to act on the fully rendered narrative.
- Hyperlinks following the Peruácru convention are now rendered as clickable
links in the web Document renderer.
Text blocks ending with a URL (e.g., `{Example https://example.com}`) render
as anchor tags.

# v4.0.3

- Regenerated `package-lock.json`, dropping references to unpm from these packages:
Expand Down
13 changes: 13 additions & 0 deletions HACKNI.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,19 @@ The Kni document serves as both renderer and dialog controller.
The web dialog is relatively simple, building a DOM on the fly, with certain CSS
to allow the containing document to govern its position and animation.

The Document constructor accepts an options object with the following properties:

- `createPage(webDocument, kniDocument)` is a function that creates a new page element.
- `meterFaultButton` is a button element that the document will use to display
meter faults.
- `pageTurnBehavior` controls how the prior frame is handled when starting a new
page. The options are 'log' (default, removes options but keeps narrative),
'remove' (removes the entire prior frame), and 'fade' (fades out and removes
the prior frame with a CSS transition, which requires a transition style to
exist on the frame element).

If a text block like `{Example https://example.com}` ends with a URL, the
web `Document` renderer will embed a hyperlink instead of the verbatim URL.

## The Command Line Dialog Renderer

Expand Down
13 changes: 13 additions & 0 deletions MANUAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,19 @@ supports some operators that assist making common typographical niceties.
- ``--`` is good for an en-dash, suitable for use in number ranges like 1–10.
- ``---`` is good for em-dash—suitable for parenthetical phrases.

## Hyperlinks

Kni supports hyperlinks in text blocks. The recommended convention is to
express hyperlinks in stand-alone blocks with the link text followed by a URL:

```
{Example https://example.com}
```

The web Document renderer recognizes this pattern and renders it as a clickable
anchor tag. The link text becomes the visible text, and the URL becomes the
href. Links open in a new tab with appropriate security attributes.

## Multiple Files

A Kni story can span multiple files. Pass all of these files to the `kni`
Expand Down
46 changes: 37 additions & 9 deletions document.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
const linkMatcher = /\s*(\w+:\/\/\S+)$/;

export default class Document {
constructor(element, createPage, meterFaultButton) {
constructor(element, options = {}) {
const {
createPage = undefined,
meterFaultButton = undefined,
pageTurnBehavior = 'log',
} = options;

const self = this;
this.document = element.ownerDocument;
this.parent = element;
Expand All @@ -21,6 +29,7 @@ export default class Document {
};
this.createPage = createPage || this.createPage;
this.meterFaultButton = meterFaultButton;
this.pageTurnBehavior = pageTurnBehavior;

Object.seal(this);
}
Expand All @@ -40,8 +49,22 @@ export default class Document {
this.br = false;
lift = '';
}
// TODO merge with prior text node
this.cursor.appendChild(document.createTextNode(lift + text));
const match = linkMatcher.exec(text);
if (match === null) {
// TODO merge with prior text node
this.cursor.appendChild(document.createTextNode(lift + text));
} else {
// Support a hyperlink convention.
if (lift !== '') {
this.cursor.appendChild(document.createTextNode(lift));
}
const link = document.createElement('a');
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use 'const' or 'let' instead of 'var' for the link variable declaration. Using 'var' is inconsistent with modern JavaScript best practices and can lead to unexpected scoping behavior.

Copilot uses AI. Check for mistakes.
link.href = match[1];
link.target = '_blank';
link.rel = 'noreferrer';
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing 'noopener' in the rel attribute. When using target='_blank', it's a security best practice to include both 'noopener' and 'noreferrer' to prevent the new page from accessing the window.opener property and protect against potential security vulnerabilities.

Suggested change
link.rel = 'noreferrer';
link.rel = 'noreferrer noopener';

Copilot uses AI. Check for mistakes.
link.appendChild(document.createTextNode(text.slice(0, match.index)));
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hyperlink implementation does not handle the case where the link text itself is empty (when match.index equals text.length). This would create an anchor element with no visible text, which is confusing for users and bad for accessibility.

Suggested change
link.appendChild(document.createTextNode(text.slice(0, match.index)));
const linkText = text.slice(0, match.index);
link.appendChild(document.createTextNode(linkText !== '' ? linkText : match[1]));

Copilot uses AI. Check for mistakes.
this.cursor.appendChild(link);
Comment on lines +54 to +66
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent indentation: this block uses 4 spaces while the rest of the file appears to use 2 spaces. Maintain consistent indentation throughout the file.

Suggested change
// TODO merge with prior text node
this.cursor.appendChild(document.createTextNode(lift + text));
} else {
// Support a hyperlink convention.
if (lift !== '') {
this.cursor.appendChild(document.createTextNode(lift));
}
const link = document.createElement('a');
link.href = match[1];
link.target = '_blank';
link.rel = 'noreferrer';
link.appendChild(document.createTextNode(text.slice(0, match.index)));
this.cursor.appendChild(link);
// TODO merge with prior text node
this.cursor.appendChild(document.createTextNode(lift + text));
} else {
// Support a hyperlink convention.
if (lift !== '') {
this.cursor.appendChild(document.createTextNode(lift));
}
const link = document.createElement('a');
link.href = match[1];
link.target = '_blank';
link.rel = 'noreferrer';
link.appendChild(document.createTextNode(text.slice(0, match.index)));
this.cursor.appendChild(link);

Copilot uses AI. Check for mistakes.
}
this.carry = drop;
}

Expand Down Expand Up @@ -100,9 +123,15 @@ export default class Document {

clear() {
if (this.frame) {
this.frame.style.opacity = 0;
this.frame.style.transform = 'translateX(-2ex)';
this.frame.addEventListener('transitionend', this);
if (this.pageTurnBehavior === 'log') {
this.options.remove();
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The case 'log' appears to reference 'this.options.remove()' but should reference 'this.frame.remove()'. The 'log' behavior seems intended to keep previous pages in the document (for logging), but currently it only removes the options element, not handling the entire frame appropriately. This likely causes a bug where frames accumulate incorrectly.

Suggested change
this.options.remove();
this.frame.remove();

Copilot uses AI. Check for mistakes.
} else if (this.pageTurnBehavior === 'remove') {
this.frame.remove();
} else if (this.pageTurnBehavior === 'fade') {
this.frame.style.opacity = 0;
this.frame.style.transform = 'translateX(-2ex)';
this.frame.addEventListener('transitionend', this);
}
}
this.createPage(this.document, this);
this.cursor = null;
Expand Down Expand Up @@ -141,9 +170,8 @@ export default class Document {
}

handleEvent(event) {
if (event.target.parentNode === this.parent) {
this.parent.removeChild(event.target);
}
// transitionend on this.frame, only
event.target.remove();
}

meterFault() {
Expand Down
2 changes: 1 addition & 1 deletion engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,10 +163,10 @@ export default class Engine {
}

end() {
this.display();
if (this.handler && this.handler.end) {
this.handler.end(this);
}
this.display();
this.dialog.close();
return false;
}
Expand Down
5 changes: 4 additions & 1 deletion entry.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ meterFaultButton.addEventListener('click', () => {
engine.clearMeterFault();
});

const doc = new Document(document.body, null, meterFaultButton);
const doc = new Document(document.body, {
meterFaultButton,
pageTurnBehavior: 'fade',
});

const engine = new Engine({
story: story,
Expand Down