diff --git a/CHANGELOG.md b/CHANGELOG.md
index ed7079f4..b6b4057c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
 # Changelog for chessops
 
+## v0.14.0
+
+- Change package layout to hybrid cjs/esm with subpath exports.
+- `chessops/pgn`:
+  - Add `Node.mainlineNodes()`.
+  - Add `node.end()`.
+  - Add `extend()`.
+
 ## v0.13.0
 
 - `Position.fromSetup()` now only checks minimum validity requirements.
diff --git a/package.json b/package.json
index e130f5b5..2ef8ce99 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "chessops",
-  "version": "0.13.0",
+  "version": "0.14.0",
   "description": "Chess and chess variant rules and operations",
   "keywords": [
     "chess",
diff --git a/src/pgn.test.ts b/src/pgn.test.ts
index 8a039b08..3a918679 100644
--- a/src/pgn.test.ts
+++ b/src/pgn.test.ts
@@ -9,8 +9,6 @@ import {
   extend,
   Game,
   isChildNode,
-  mainline,
-  mainlineEnd,
   makeComment,
   makePgn,
   Node,
@@ -85,7 +83,7 @@ test('make pgn', () => {
 
 test('extend mainline', () => {
   const game: Game<PgnNodeData> = defaultGame(emptyHeaders);
-  extend(mainlineEnd(game.moves), 'e4 d5 a3 h6 Bg5'.split(' ').map(san => ({ san })));
+  extend(game.moves.end(), 'e4 d5 a3 h6 Bg5'.split(' ').map(san => ({ san })));
   expect(makePgn(game)).toEqual('1. e4 d5 2. a3 h6 3. Bg5 *\n');
 });
 
@@ -161,7 +159,7 @@ testPgnFile(
   },
   game => {
     expect(game.headers.get('Variant')).toBe('Antichess');
-    expect(Array.from(mainline(game.moves)).map(move => move.san)).toStrictEqual(['e3', 'e6', 'b4', 'Bxb4', 'Qg4']);
+    expect(Array.from(game.moves.mainline()).map(move => move.san)).toStrictEqual(['e3', 'e6', 'b4', 'Bxb4', 'Qg4']);
   },
 );
 testPgnFile(
@@ -188,12 +186,12 @@ testPgnFile(
     allValid: true,
   },
   game => {
-    expect(Array.from(mainline(game.moves)).map(move => move.san)).toStrictEqual(['e4', 'e5', 'Nf3', 'Nc6', 'Bb5']);
+    expect(Array.from(game.moves.mainline()).map(move => move.san)).toStrictEqual(['e4', 'e5', 'Nf3', 'Nc6', 'Bb5']);
   },
 );
 
 test('tricky tokens', () => {
-  const steps = Array.from(mainline(parsePgn('O-O-O !! 0-0-0# ??')[0].moves));
+  const steps = Array.from(parsePgn('O-O-O !! 0-0-0# ??')[0].moves.mainline());
   expect(steps[0].san).toBe('O-O-O');
   expect(steps[0].nags).toEqual([3]);
   expect(steps[1].san).toBe('O-O-O#');
diff --git a/src/pgn.ts b/src/pgn.ts
index cd623ff7..5944f9a4 100644
--- a/src/pgn.ts
+++ b/src/pgn.ts
@@ -120,32 +120,34 @@ export const defaultGame = <T>(initHeaders: () => Map<string, string> = defaultH
 
 export class Node<T> {
   children: ChildNode<T>[] = [];
-}
 
-export class ChildNode<T> extends Node<T> {
-  constructor(public data: T) {
-    super();
+  *mainlineNodes(): Iterable<ChildNode<T>> {
+    let node: Node<T> = this;
+    while (node.children.length) {
+      const child = node.children[0];
+      yield child;
+      node = child;
+    }
   }
-}
 
-export const isChildNode = <T>(node: Node<T>): node is ChildNode<T> => node instanceof ChildNode;
+  *mainline(): Iterable<T> {
+    for (const child of this.mainlineNodes()) yield child.data;
+  }
 
-export function* mainlineNodes<T>(node: Node<T>): Iterable<ChildNode<T>> {
-  while (node.children.length) {
-    const child = node.children[0];
-    yield child;
-    node = child;
+  end(): Node<T> {
+    let node: Node<T> = this;
+    while (node.children.length) node = node.children[0];
+    return node;
   }
 }
 
-export function* mainline<T>(node: Node<T>): Iterable<T> {
-  for (const child of mainlineNodes(node)) yield child.data;
+export class ChildNode<T> extends Node<T> {
+  constructor(public data: T) {
+    super();
+  }
 }
 
-export function mainlineEnd<T>(node: Node<T>): Node<T> {
-  while (node.children.length) node = node.children[0];
-  return node;
-}
+export const isChildNode = <T>(node: Node<T>): node is ChildNode<T> => node instanceof ChildNode;
 
 export const extend = <T>(node: Node<T>, data: T[]): Node<T> => {
   for (const d of data) {