kinsite 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +66 -2
- package/build.mjs +21 -0
- package/lib/build.mjs +71 -0
- package/lib/config.mjs +25 -0
- package/lib/css.mjs +56 -0
- package/lib/dates.mjs +7 -0
- package/lib/gedcom.mjs +103 -0
- package/lib/hourglass.mjs +141 -0
- package/lib/html.mjs +2 -0
- package/lib/kin.mjs +58 -0
- package/lib/pii.mjs +36 -0
- package/lib/redact.mjs +24 -0
- package/lib/render.mjs +211 -0
- package/package.json +34 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 chrismo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,67 @@
|
|
|
1
|
-
#
|
|
1
|
+
# kinsite
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A static family-tree website generator. Give it a GEDCOM file; get back plain HTML
|
|
4
|
+
pages for every person, with a readable family chart, that you can host anywhere.
|
|
5
|
+
|
|
6
|
+
**Status: early.** The generator works and has tests, but the config format and
|
|
7
|
+
output may still change.
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
Needs Node 22 or later; no dependencies.
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
node build.mjs sample/kinsite.config.json # build the fictional sample tree into site/
|
|
15
|
+
open site/index.html
|
|
16
|
+
npm test
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
For your own tree, write a `kinsite.config.json` next to your GEDCOM:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"gedcom": "my-tree.ged",
|
|
24
|
+
"title": "My Family Tree",
|
|
25
|
+
"focus": "I1",
|
|
26
|
+
"out": "site",
|
|
27
|
+
"mediaRoot": "."
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
| Key | Meaning | Default |
|
|
32
|
+
|-------------|-------------------------------------------------------------------------------|------------------------|
|
|
33
|
+
| `gedcom` | GEDCOM 7.0 file to read (required) | |
|
|
34
|
+
| `title` | Site title on the index page | `Family Tree` |
|
|
35
|
+
| `focus` | ID of the person the site centers on: home-page chart and every "Path to ..." | first person |
|
|
36
|
+
| `out` | Output directory (deleted and rebuilt on each run) | `site` |
|
|
37
|
+
| `mediaRoot` | Base directory for relative `FILE` paths of photos and documents | the config's directory |
|
|
38
|
+
|
|
39
|
+
Relative paths resolve against the config file's directory. `FOCUS=I3` or `OUT=dir` in
|
|
40
|
+
the environment override the config for one run.
|
|
41
|
+
|
|
42
|
+
Everything in `sample/` is invented: names, places, dates, numbers and addresses.
|
|
43
|
+
|
|
44
|
+
## Goals
|
|
45
|
+
|
|
46
|
+
- **Static output only.** No server, no database. JavaScript in the browser is fine,
|
|
47
|
+
but every page must be readable without it.
|
|
48
|
+
- **No contact details.** The build removes phone numbers, email addresses, street
|
|
49
|
+
addresses and PO boxes from places and notes; never reads contact fields (`ADDR`,
|
|
50
|
+
`PHON`, `EMAIL`); and hides events marked `RESN PRIVACY` or `CONFIDENTIAL`.
|
|
51
|
+
- **Living people suited to the audience.** Living people are not detected, so
|
|
52
|
+
their names and pages appear. That's fine for a site behind proper authorization.
|
|
53
|
+
For a public site, leave them out of the GEDCOM you build from, or at least set
|
|
54
|
+
`RESN PRIVACY` on their events.
|
|
55
|
+
- **A chart that stays readable.** A focused neighborhood around one person, never a
|
|
56
|
+
whole tree shrunk until the text is useless.
|
|
57
|
+
- **Works on a phone.**
|
|
58
|
+
- **Stable URLs.** A person keeps the same page address across rebuilds.
|
|
59
|
+
|
|
60
|
+
## Contributing
|
|
61
|
+
|
|
62
|
+
Ideas, bugs and design notes are welcome as issues. Please don't paste real genealogy
|
|
63
|
+
data of living people into an issue; invent a sample instead.
|
|
64
|
+
|
|
65
|
+
## License
|
|
66
|
+
|
|
67
|
+
[MIT](LICENSE)
|
package/build.mjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Static HTML family-tree site from a GEDCOM file.
|
|
3
|
+
//
|
|
4
|
+
// Usage:
|
|
5
|
+
// node build.mjs path/to/kinsite.config.json
|
|
6
|
+
// FOCUS=I3 node build.mjs kinsite.config.json # re-center on another person
|
|
7
|
+
// OUT=/tmp/site node build.mjs kinsite.config.json
|
|
8
|
+
import { loadConfig } from './lib/config.mjs'
|
|
9
|
+
import { buildSite } from './lib/build.mjs'
|
|
10
|
+
|
|
11
|
+
const file = process.argv[2]
|
|
12
|
+
if (!file) {
|
|
13
|
+
console.error('usage: node build.mjs path/to/kinsite.config.json')
|
|
14
|
+
process.exit(2)
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
buildSite(loadConfig(file, { focus: process.env.FOCUS, out: process.env.OUT }))
|
|
18
|
+
} catch (e) {
|
|
19
|
+
console.error(`error: ${e.message}`)
|
|
20
|
+
process.exit(1)
|
|
21
|
+
}
|
package/lib/build.mjs
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { parseGedcom } from './gedcom.mjs'
|
|
4
|
+
import { Kin } from './kin.mjs'
|
|
5
|
+
import { Renderer } from './render.mjs'
|
|
6
|
+
import { CSS } from './css.mjs'
|
|
7
|
+
|
|
8
|
+
export function buildSite(config, { log = console.log } = {}) {
|
|
9
|
+
const { gedcom, out, mediaRoot, title, focus } = config
|
|
10
|
+
if (!fs.statSync(gedcom, { throwIfNoEntry: false })?.isFile()) throw new Error(`GEDCOM not found: ${gedcom}`)
|
|
11
|
+
const mediaOut = path.join(out, 'media')
|
|
12
|
+
const inside = (dir, f) => {
|
|
13
|
+
const rel = path.relative(dir, f)
|
|
14
|
+
return !path.isAbsolute(rel) && rel.split(path.sep)[0] !== '..'
|
|
15
|
+
}
|
|
16
|
+
for (const keep of [gedcom, mediaRoot]) {
|
|
17
|
+
if (inside(out, keep)) throw new Error(`refusing to delete ${out}: it contains ${keep}`)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
log(`Building site from: ${gedcom}`)
|
|
21
|
+
log(` into : ${out}`)
|
|
22
|
+
fs.rmSync(out, { recursive: true, force: true })
|
|
23
|
+
fs.mkdirSync(mediaOut, { recursive: true })
|
|
24
|
+
|
|
25
|
+
const { indi, fam } = parseGedcom(fs.readFileSync(gedcom, 'utf8'))
|
|
26
|
+
|
|
27
|
+
const copied = new Map()
|
|
28
|
+
const isFile = f => fs.statSync(f, { throwIfNoEntry: false })?.isFile() ?? false
|
|
29
|
+
for (const p of indi.values()) {
|
|
30
|
+
for (const md of p.media) {
|
|
31
|
+
const f = md.file
|
|
32
|
+
if (!f || copied.has(f)) continue
|
|
33
|
+
const src = path.isAbsolute(f) && fs.existsSync(f) ? f : path.join(mediaRoot, f)
|
|
34
|
+
if (!isFile(src)) continue
|
|
35
|
+
const base = path.basename(f)
|
|
36
|
+
let dst = path.join(mediaOut, base)
|
|
37
|
+
for (let n = 2; fs.existsSync(dst); n++) {
|
|
38
|
+
const ext = path.extname(base)
|
|
39
|
+
dst = path.join(mediaOut, `${base.slice(0, base.length - ext.length)}-${n}${ext}`)
|
|
40
|
+
}
|
|
41
|
+
fs.copyFileSync(src, dst)
|
|
42
|
+
const { atime, mtime } = fs.statSync(src)
|
|
43
|
+
fs.utimesSync(dst, atime, mtime)
|
|
44
|
+
copied.set(f, 'media/' + path.basename(dst))
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
fs.writeFileSync(path.join(out, 'style.css'), CSS)
|
|
49
|
+
|
|
50
|
+
const kin = new Kin(indi, fam, focus)
|
|
51
|
+
const r = new Renderer({ indi, fam, kin, copied, title, source: path.basename(gedcom) })
|
|
52
|
+
let withPhoto = 0
|
|
53
|
+
for (const pid of indi.keys()) {
|
|
54
|
+
const page = r.personPage(pid)
|
|
55
|
+
if (page.withPhoto) withPhoto++
|
|
56
|
+
fs.writeFileSync(path.join(out, `${pid}.html`), page.html)
|
|
57
|
+
}
|
|
58
|
+
fs.writeFileSync(path.join(out, 'index.html'), r.indexPage())
|
|
59
|
+
|
|
60
|
+
const stats = {
|
|
61
|
+
individuals: indi.size, families: fam.size, withPhoto, media: copied.size,
|
|
62
|
+
pages: fs.readdirSync(out).filter(f => f.endsWith('.html')).length,
|
|
63
|
+
}
|
|
64
|
+
log(` individuals : ${stats.individuals}`)
|
|
65
|
+
log(` families : ${stats.families}`)
|
|
66
|
+
log(` with photos : ${stats.withPhoto}`)
|
|
67
|
+
log(` media copied: ${stats.media}`)
|
|
68
|
+
log(`Wrote ${stats.pages} HTML pages.`)
|
|
69
|
+
log(`Open it: open "${path.join(out, 'index.html')}"`)
|
|
70
|
+
return stats
|
|
71
|
+
}
|
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Site config: a JSON file whose relative paths resolve against its own directory.
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { DEFAULT_TITLE } from './render.mjs'
|
|
5
|
+
|
|
6
|
+
export const DEFAULTS = {
|
|
7
|
+
title: DEFAULT_TITLE,
|
|
8
|
+
focus: null,
|
|
9
|
+
out: 'site',
|
|
10
|
+
mediaRoot: '.',
|
|
11
|
+
}
|
|
12
|
+
const PATHS = ['gedcom', 'out', 'mediaRoot']
|
|
13
|
+
|
|
14
|
+
export function loadConfig(file, overrides = {}) {
|
|
15
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
16
|
+
for (const k of Object.keys(raw)) {
|
|
17
|
+
if (k !== 'gedcom' && !(k in DEFAULTS)) throw new Error(`unknown config key: ${k} (in ${file})`)
|
|
18
|
+
}
|
|
19
|
+
const set = Object.fromEntries(Object.entries(overrides).filter(([, v]) => v !== undefined))
|
|
20
|
+
const c = { ...DEFAULTS, ...raw, ...set }
|
|
21
|
+
if (!c.gedcom) throw new Error(`config needs a "gedcom" path (in ${file})`)
|
|
22
|
+
const base = path.dirname(path.resolve(file))
|
|
23
|
+
for (const k of PATHS) c[k] = path.resolve(base, c[k])
|
|
24
|
+
return c
|
|
25
|
+
}
|
package/lib/css.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export const CSS = `
|
|
2
|
+
:root{--ink:#222;--mut:#777;--line:#e3e0d8;--bg:#faf8f4;--accent:#7a5c3e}
|
|
3
|
+
*{box-sizing:border-box}
|
|
4
|
+
body{font:16px/1.55 -apple-system,Segoe UI,Roboto,sans-serif;color:var(--ink);
|
|
5
|
+
background:var(--bg);margin:0}
|
|
6
|
+
.wrap{max-width:820px;margin:0 auto;padding:24px}
|
|
7
|
+
h1{font-family:Georgia,serif;font-weight:700;margin:.2em 0}
|
|
8
|
+
a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
|
|
9
|
+
.sub{color:var(--mut);margin:0 0 1.2em}
|
|
10
|
+
.back{font-size:.9em}
|
|
11
|
+
.card{background:#fff;border:1px solid var(--line);border-radius:10px;
|
|
12
|
+
padding:18px 20px;margin:14px 0}
|
|
13
|
+
.evt{margin:.15em 0}.lbl{display:inline-block;min-width:64px;color:var(--mut);
|
|
14
|
+
font-size:.85em;text-transform:uppercase;letter-spacing:.04em}
|
|
15
|
+
.yr{color:var(--mut);font-size:.9em}
|
|
16
|
+
.unknown{color:var(--mut)}
|
|
17
|
+
h2{font-size:.95em;text-transform:uppercase;letter-spacing:.05em;color:var(--mut);
|
|
18
|
+
border-bottom:1px solid var(--line);padding-bottom:4px;margin:1.4em 0 .6em}
|
|
19
|
+
ul.rel{list-style:none;padding:0;margin:0}ul.rel li{margin:.2em 0}
|
|
20
|
+
.photos{display:flex;flex-wrap:wrap;gap:12px;margin:.4em 0}
|
|
21
|
+
.photos figure{margin:0;width:200px}
|
|
22
|
+
.photos img{width:100%;border:1px solid var(--line);border-radius:6px;display:block}
|
|
23
|
+
.photos figcaption{font-size:.8em;color:var(--mut);margin-top:4px}
|
|
24
|
+
.docs a{display:inline-block;margin:.2em .6em .2em 0}
|
|
25
|
+
#q{width:100%;padding:10px 12px;font-size:1em;border:1px solid var(--line);
|
|
26
|
+
border-radius:8px;margin:.6em 0 .4em}
|
|
27
|
+
.only{display:block;color:var(--mut);font-size:.9em;margin:0 0 1em;cursor:pointer}
|
|
28
|
+
.cam{font-size:.85em}
|
|
29
|
+
ul.people{list-style:none;padding:0;margin:0;columns:2;column-gap:28px}
|
|
30
|
+
@media(max-width:640px){ul.people{columns:1}}
|
|
31
|
+
ul.people li{margin:.12em 0;break-inside:avoid}
|
|
32
|
+
details{margin:.5em 0}summary{cursor:pointer;color:var(--mut)}
|
|
33
|
+
.note{white-space:pre-wrap;font-size:.92em;color:#444;border-left:3px solid var(--line);
|
|
34
|
+
padding-left:10px;margin:.4em 0}
|
|
35
|
+
footer{color:var(--mut);font-size:.85em;margin:2em 0 1em;border-top:1px solid var(--line);
|
|
36
|
+
padding-top:10px}
|
|
37
|
+
.pedwrap{overflow-x:auto;border:1px solid var(--line);border-radius:10px;background:#fff;
|
|
38
|
+
padding:12px;margin:.5em 0}
|
|
39
|
+
svg.hg{display:block}
|
|
40
|
+
svg.hg text{font:12px -apple-system,Segoe UI,sans-serif;fill:var(--ink)}
|
|
41
|
+
svg.hg .hg-yr{fill:var(--mut);font-size:11px}
|
|
42
|
+
svg.hg path{fill:none;stroke:#d3c4ad;stroke-width:1.5}
|
|
43
|
+
svg.hg rect{fill:#fbf7f0;stroke:#d8cdbb}
|
|
44
|
+
svg.hg .hg-self{fill:#efe3d2;stroke:var(--accent);stroke-width:1.6}
|
|
45
|
+
svg.hg a{cursor:pointer}svg.hg a:hover rect{fill:#f3e9d9}
|
|
46
|
+
svg.hg .hg-stub rect,svg.hg .hg-more rect{fill:none;stroke-dasharray:4 3}
|
|
47
|
+
svg.hg .hg-stub text,svg.hg .hg-more text{fill:var(--mut);font-style:italic}
|
|
48
|
+
.descent{background:#f1ece2;border:1px solid var(--line);border-radius:8px;
|
|
49
|
+
padding:9px 13px;margin:.5em 0;font-size:.95em;line-height:1.7}
|
|
50
|
+
.descent .lbl{display:block;color:var(--mut);font-size:.8em;text-transform:uppercase;
|
|
51
|
+
letter-spacing:.04em;margin-bottom:3px}
|
|
52
|
+
.descent .hop{color:var(--accent);font-weight:700;padding:0 1px}
|
|
53
|
+
ul.tree{list-style:none;margin:.2em 0;padding-left:15px;border-left:1px solid var(--line)}
|
|
54
|
+
ul.tree ul.tree{margin-left:2px}
|
|
55
|
+
ul.tree li{margin:.16em 0}
|
|
56
|
+
`
|
package/lib/dates.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const yr = d => (d || '').match(/\d{4}/)?.[0] ?? ''
|
|
2
|
+
|
|
3
|
+
/** Sort key by birth year; people without one sort last and keep their order. */
|
|
4
|
+
export const byBirth = indi => (a, b) => {
|
|
5
|
+
const ya = yr(indi.get(a)?.birt?.date), yb = yr(indi.get(b)?.birt?.date)
|
|
6
|
+
return (ya ? Number(ya) : Infinity) - (yb ? Number(yb) : Infinity)
|
|
7
|
+
}
|
package/lib/gedcom.mjs
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Minimal GEDCOM 7.0 reader: only what the site renders.
|
|
2
|
+
|
|
3
|
+
const EVENTS = new Set(['BIRT', 'CHR', 'BAPM', 'DEAT', 'BURI', 'RESI', 'EMIG', 'OCCU', 'EVEN'])
|
|
4
|
+
|
|
5
|
+
export function parseGedcom(text) {
|
|
6
|
+
const indi = new Map(), fam = new Map(), obje = new Map()
|
|
7
|
+
let cur = null, ctype = null, evt = null, media = null, note = false, sub = null
|
|
8
|
+
|
|
9
|
+
for (const ln of text.replace(/\r\n?/g, '\n').split('\n')) {
|
|
10
|
+
if (!ln.trim()) continue
|
|
11
|
+
const m = ln.match(/^(\d+) ([^\n]*)$/)
|
|
12
|
+
if (!m) continue
|
|
13
|
+
const lvl = Number(m[1]), rest = m[2]
|
|
14
|
+
if (lvl === 0) {
|
|
15
|
+
cur = ctype = evt = media = null
|
|
16
|
+
note = false
|
|
17
|
+
const r = rest.match(/^@(\w+)@ (\w+)$/)
|
|
18
|
+
if (r && r[2] === 'INDI') {
|
|
19
|
+
ctype = 'INDI'
|
|
20
|
+
if (!indi.has(r[1])) indi.set(r[1], {
|
|
21
|
+
id: r[1], name: '', surn: '', giv: '', sex: '',
|
|
22
|
+
birt: {}, deat: {}, events: [], famc: null, fams: [], media: [], notes: [],
|
|
23
|
+
})
|
|
24
|
+
cur = indi.get(r[1])
|
|
25
|
+
} else if (r && r[2] === 'FAM') {
|
|
26
|
+
ctype = 'FAM'
|
|
27
|
+
if (!fam.has(r[1])) fam.set(r[1], { id: r[1], husb: null, wife: null, chil: [], marr: {} })
|
|
28
|
+
cur = fam.get(r[1])
|
|
29
|
+
} else if (r && r[2] === 'OBJE') {
|
|
30
|
+
ctype = 'OBJE'
|
|
31
|
+
cur = { file: '', title: '', form: '' }
|
|
32
|
+
obje.set(r[1], cur)
|
|
33
|
+
}
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
if (cur === null) continue
|
|
37
|
+
const sp = rest.indexOf(' ')
|
|
38
|
+
const tag = sp < 0 ? rest : rest.slice(0, sp)
|
|
39
|
+
let val = sp < 0 ? '' : rest.slice(sp + 1)
|
|
40
|
+
if (val.startsWith('@@')) val = val.slice(1)
|
|
41
|
+
if (lvl === 2) sub = tag
|
|
42
|
+
const phrase = lvl === 3 && sub === 'DATE' && tag === 'PHRASE'
|
|
43
|
+
if (ctype === 'OBJE') {
|
|
44
|
+
if (lvl === 1) {
|
|
45
|
+
media = tag === 'FILE' && !cur.file ? cur : null
|
|
46
|
+
if (media) media.file = val.trim()
|
|
47
|
+
} else if (lvl === 2 && media) {
|
|
48
|
+
if (tag === 'TITL') media.title = val.trim()
|
|
49
|
+
else if (tag === 'FORM') media.form = val.trim()
|
|
50
|
+
}
|
|
51
|
+
} else if (ctype === 'INDI') {
|
|
52
|
+
if (lvl === 1) {
|
|
53
|
+
evt = null
|
|
54
|
+
note = false
|
|
55
|
+
if (tag === 'NAME' && !cur.name) {
|
|
56
|
+
cur.name = val.replaceAll('/', '').trim()
|
|
57
|
+
const s = val.match(/\/([^/]*)\//)
|
|
58
|
+
cur.surn = s ? s[1].trim() : ''
|
|
59
|
+
cur.giv = val.replace(/\/[^/]*\//g, '').trim()
|
|
60
|
+
} else if (tag === 'SEX') cur.sex = val.trim()
|
|
61
|
+
else if (EVENTS.has(tag)) {
|
|
62
|
+
evt = { tag, val: val.trim() }
|
|
63
|
+
cur.events.push(evt)
|
|
64
|
+
if (tag === 'BIRT' && !cur.birt.tag) cur.birt = evt
|
|
65
|
+
if (tag === 'DEAT' && !cur.deat.tag) cur.deat = evt
|
|
66
|
+
} else if (tag === 'FAMC') cur.famc = stripAt(val)
|
|
67
|
+
else if (tag === 'FAMS') cur.fams.push(stripAt(val))
|
|
68
|
+
else if (tag === 'OBJE') cur.media.push(stripAt(val))
|
|
69
|
+
else if (tag === 'NOTE') {
|
|
70
|
+
cur.notes.push(val)
|
|
71
|
+
note = true
|
|
72
|
+
}
|
|
73
|
+
} else if (evt && lvl === 2) {
|
|
74
|
+
if (tag === 'DATE') evt.date = val.trim()
|
|
75
|
+
else if (tag === 'PLAC') evt.plac = val.trim()
|
|
76
|
+
else if (tag === 'TYPE') evt.type = val.trim()
|
|
77
|
+
else if (tag === 'RESN') evt.resn = val.trim()
|
|
78
|
+
} else if (evt && phrase) {
|
|
79
|
+
evt.phrase = val.trim()
|
|
80
|
+
} else if (note && tag === 'CONT') {
|
|
81
|
+
cur.notes[cur.notes.length - 1] += '\n' + val
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
if (lvl === 1) {
|
|
85
|
+
evt = null
|
|
86
|
+
if (tag === 'HUSB') cur.husb = stripAt(val)
|
|
87
|
+
else if (tag === 'WIFE') cur.wife = stripAt(val)
|
|
88
|
+
else if (tag === 'CHIL') cur.chil.push(stripAt(val))
|
|
89
|
+
else if (tag === 'MARR') evt = cur.marr
|
|
90
|
+
} else if (evt && lvl === 2) {
|
|
91
|
+
if (tag === 'DATE') evt.date = val.trim()
|
|
92
|
+
else if (tag === 'PLAC') evt.plac = val.trim()
|
|
93
|
+
else if (tag === 'RESN') evt.resn = val.trim()
|
|
94
|
+
} else if (evt && phrase) evt.phrase = val.trim()
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
for (const p of indi.values()) {
|
|
98
|
+
p.media = p.media.flatMap(id => obje.get(id) ?? [])
|
|
99
|
+
}
|
|
100
|
+
return { indi, fam }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const stripAt = s => s.replace(/^@+|@+$/g, '')
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Static SVG hourglass chart for one person: grandparents and parents above,
|
|
2
|
+
// siblings and spouses beside, children below. Long lists collapse into a
|
|
3
|
+
// "+N more" box so the chart stays a readable neighbourhood, never a whole tree.
|
|
4
|
+
import { esc } from './html.mjs'
|
|
5
|
+
import { byBirth } from './dates.mjs'
|
|
6
|
+
|
|
7
|
+
export const BOX = { w: 150, h: 44 }
|
|
8
|
+
export const CAPS = { sibs: 3, spouses: 2, kids: 4 } // sibs is per side of the focus
|
|
9
|
+
const HGAP = 18, VGAP = 34, PAD = 8
|
|
10
|
+
const STEP = BOX.w + HGAP
|
|
11
|
+
const NAME_CHARS = 22
|
|
12
|
+
|
|
13
|
+
// Up to `cap` boxes, keeping the ones nearest the focus; the rest become one "+N more".
|
|
14
|
+
function capped(list, cap, nearestLast = false) {
|
|
15
|
+
if (list.length <= cap) return { shown: list, more: 0 }
|
|
16
|
+
const n = cap - 1
|
|
17
|
+
return { shown: nearestLast ? list.slice(-n) : list.slice(0, n), more: list.length - n }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function hourglassLayout(pid, { indi, fam }, caps = CAPS) {
|
|
21
|
+
const has = id => (id && indi.has(id) ? id : null)
|
|
22
|
+
const parentsOf = id => {
|
|
23
|
+
const f = fam.get(indi.get(id)?.famc)
|
|
24
|
+
return f ? [has(f.husb), has(f.wife)].filter(Boolean) : []
|
|
25
|
+
}
|
|
26
|
+
const parents = parentsOf(pid)
|
|
27
|
+
const chil = (fam.get(indi.get(pid)?.famc)?.chil ?? []).filter(c => c === pid || indi.has(c)).sort(byBirth(indi))
|
|
28
|
+
const older = chil.slice(0, chil.indexOf(pid)), younger = chil.slice(chil.indexOf(pid) + 1)
|
|
29
|
+
const spouses = [], kids = []
|
|
30
|
+
for (const fid of indi.get(pid)?.fams ?? []) {
|
|
31
|
+
const f = fam.get(fid)
|
|
32
|
+
if (!f) continue
|
|
33
|
+
const sp = has(f.husb === pid ? f.wife : f.husb)
|
|
34
|
+
if (sp) spouses.push(sp)
|
|
35
|
+
kids.push(...f.chil.filter(c => indi.has(c)))
|
|
36
|
+
}
|
|
37
|
+
kids.sort(byBirth(indi))
|
|
38
|
+
const grands = parents.map(parentsOf)
|
|
39
|
+
|
|
40
|
+
// cx is the box centre relative to the focus at 0, resolved to x once every row is placed.
|
|
41
|
+
const boxes = [], edges = []
|
|
42
|
+
const put = (row, cx, props) => {
|
|
43
|
+
const b = { row, cx, ...props }
|
|
44
|
+
boxes.push(b)
|
|
45
|
+
return b
|
|
46
|
+
}
|
|
47
|
+
const person = (row, cx, id) => put(row, cx, { id, kind: 'person' })
|
|
48
|
+
const more = (row, cx, n) => put(row, cx, { id: null, kind: 'more', text: `+${n} more` })
|
|
49
|
+
|
|
50
|
+
// Focus row: [+N more] older siblings | focus | spouses [+N more] | younger siblings [+N more]
|
|
51
|
+
const self = put('focus', 0, { id: pid, kind: 'self' })
|
|
52
|
+
const O = capped(older, caps.sibs, true)
|
|
53
|
+
const sibBoxes = O.shown.map((id, i) => person('focus', -(O.shown.length - i) * STEP, id))
|
|
54
|
+
if (O.more) sibBoxes.unshift(more('focus', -(O.shown.length + 1) * STEP, O.more))
|
|
55
|
+
const W = capped(spouses, caps.spouses)
|
|
56
|
+
const spBoxes = W.shown.map((id, i) => person('focus', (i + 1) * STEP, id))
|
|
57
|
+
if (W.more) spBoxes.push(more('focus', (W.shown.length + 1) * STEP, W.more))
|
|
58
|
+
const Y = capped(younger, caps.sibs)
|
|
59
|
+
let col = spBoxes.length
|
|
60
|
+
for (const id of Y.shown) sibBoxes.push(person('focus', ++col * STEP, id))
|
|
61
|
+
if (Y.more) sibBoxes.push(more('focus', ++col * STEP, Y.more))
|
|
62
|
+
|
|
63
|
+
const K = capped(kids, caps.kids), nK = K.shown.length + (K.more ? 1 : 0)
|
|
64
|
+
const kidX = i => (spouses.length === 1 ? STEP / 2 : 0) + (i - (nK - 1) / 2) * STEP
|
|
65
|
+
const kidBoxes = K.shown.map((id, i) => person('child', kidX(i), id))
|
|
66
|
+
if (K.more) kidBoxes.push(more('child', kidX(K.shown.length), K.more))
|
|
67
|
+
|
|
68
|
+
let parBoxes = []
|
|
69
|
+
if (parents.length === 2) {
|
|
70
|
+
const spread = grands.some(g => g.length) ? 2 * STEP : STEP
|
|
71
|
+
parBoxes = [person('parent', -spread / 2, parents[0]), person('parent', spread / 2, parents[1])]
|
|
72
|
+
} else if (parents.length === 1) {
|
|
73
|
+
parBoxes = [person('parent', 0, parents[0])]
|
|
74
|
+
} else {
|
|
75
|
+
put('parent', 0, { id: null, kind: 'stub', text: 'no parents recorded' })
|
|
76
|
+
}
|
|
77
|
+
const grandBoxes = parBoxes.map((pb, i) => {
|
|
78
|
+
const g = grands[i]
|
|
79
|
+
if (g.length === 2) return [person('grand', pb.cx - STEP / 2, g[0]), person('grand', pb.cx + STEP / 2, g[1])]
|
|
80
|
+
return g.map(id => person('grand', pb.cx, id))
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const rowNames = ['grand', 'parent', 'focus', 'child'].filter(r => boxes.some(b => b.row === r))
|
|
84
|
+
const rowY = Object.fromEntries(rowNames.map((r, i) => [r, PAD + i * (BOX.h + VGAP)]))
|
|
85
|
+
const shift = PAD + BOX.w / 2 - Math.min(...boxes.map(b => b.cx))
|
|
86
|
+
for (const b of boxes) {
|
|
87
|
+
b.x = b.cx + shift - BOX.w / 2
|
|
88
|
+
b.y = rowY[b.row]
|
|
89
|
+
delete b.cx
|
|
90
|
+
}
|
|
91
|
+
const width = Math.max(...boxes.map(b => b.x)) + BOX.w + PAD
|
|
92
|
+
const height = PAD + rowNames.length * BOX.h + (rowNames.length - 1) * VGAP + PAD
|
|
93
|
+
|
|
94
|
+
const mid = b => b.x + BOX.w / 2
|
|
95
|
+
const midY = b => b.y + BOX.h / 2
|
|
96
|
+
const couple = pair => {
|
|
97
|
+
if (pair.length === 2) edges.push([pair[0].x + BOX.w, midY(pair[0]), pair[1].x, midY(pair[1])])
|
|
98
|
+
}
|
|
99
|
+
const descend = (from, children) => {
|
|
100
|
+
if (!from.length || !children.length) return
|
|
101
|
+
const x0 = from.length === 2 ? (mid(from[0]) + mid(from[1])) / 2 : mid(from[0])
|
|
102
|
+
const y0 = from.length === 2 ? midY(from[0]) : from[0].y + BOX.h
|
|
103
|
+
const bus = children[0].y - VGAP / 2
|
|
104
|
+
const xs = children.map(mid)
|
|
105
|
+
edges.push([x0, y0, x0, bus])
|
|
106
|
+
edges.push([Math.min(x0, ...xs), bus, Math.max(x0, ...xs), bus])
|
|
107
|
+
for (const c of children) edges.push([mid(c), bus, mid(c), c.y])
|
|
108
|
+
}
|
|
109
|
+
grandBoxes.forEach((g, i) => { couple(g); descend(g, [parBoxes[i]]) })
|
|
110
|
+
couple(parBoxes)
|
|
111
|
+
descend(parBoxes, [...sibBoxes, self])
|
|
112
|
+
const row = [self, ...spBoxes]
|
|
113
|
+
for (let i = 1; i < row.length; i++) couple([row[i - 1], row[i]])
|
|
114
|
+
descend(spouses.length === 1 ? [self, spBoxes[0]] : [self], kidBoxes)
|
|
115
|
+
|
|
116
|
+
return { boxes, edges, width, height }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const chars = s => Array.from(s)
|
|
120
|
+
const trunc = s => (chars(s).length <= NAME_CHARS ? s : chars(s).slice(0, NAME_CHARS - 1).join('').trimEnd() + '…')
|
|
121
|
+
const f1 = n => (Number.isInteger(n) ? String(n) : n.toFixed(1))
|
|
122
|
+
|
|
123
|
+
/** label(id) -> [name, years] */
|
|
124
|
+
export function hourglassSvg({ boxes, edges, width, height }, label) {
|
|
125
|
+
const out = edges.map(([x1, y1, x2, y2]) =>
|
|
126
|
+
`<path d="M${f1(x1)},${f1(y1)} L${f1(x2)},${f1(y2)}"/>`)
|
|
127
|
+
for (const b of boxes) {
|
|
128
|
+
const rect = cls => `<rect${cls ? ` class="${cls}"` : ''} x="${f1(b.x)}" y="${f1(b.y)}" width="${BOX.w}" height="${BOX.h}" rx="3"/>`
|
|
129
|
+
if (b.id === null) {
|
|
130
|
+
out.push(`<g class="hg-${b.kind}">${rect()}` +
|
|
131
|
+
`<text x="${f1(b.x + BOX.w / 2)}" y="${f1(b.y + BOX.h / 2 + 4)}" text-anchor="middle">${esc(b.text)}</text></g>`)
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
const [name, years] = label(b.id)
|
|
135
|
+
out.push(`<a href="${b.id}.html">${rect(b.kind === 'self' ? 'hg-self' : 'hg-box')}<title>${esc(name)}</title>` +
|
|
136
|
+
`<text x="${f1(b.x + 8)}" y="${f1(b.y + 18)}">${esc(trunc(name))}</text>` +
|
|
137
|
+
`<text x="${f1(b.x + 8)}" y="${f1(b.y + 34)}" class="hg-yr">${esc(years)}</text></a>`)
|
|
138
|
+
}
|
|
139
|
+
return `<div class="pedwrap"><svg viewBox="0 0 ${f1(width)} ${f1(height)}" width="${f1(width)}" ` +
|
|
140
|
+
`height="${f1(height)}" xmlns="http://www.w3.org/2000/svg" class="hg">${out.join('')}</svg></div>`
|
|
141
|
+
}
|
package/lib/html.mjs
ADDED
package/lib/kin.mjs
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Shortest parent/child path from anyone to the primary person (BFS).
|
|
2
|
+
|
|
3
|
+
export class Kin {
|
|
4
|
+
constructor(indi, fam, primaryId) {
|
|
5
|
+
this.indi = indi
|
|
6
|
+
this.fam = fam
|
|
7
|
+
this.primary = indi.has(primaryId) ? primaryId : indi.has('I1') ? 'I1' : (indi.keys().next().value ?? null)
|
|
8
|
+
this.prev = new Map()
|
|
9
|
+
if (this.primary === null) return
|
|
10
|
+
this.prev.set(this.primary, null)
|
|
11
|
+
const queue = [this.primary]
|
|
12
|
+
for (let i = 0; i < queue.length; i++) {
|
|
13
|
+
for (const v of this.neighbors(queue[i])) {
|
|
14
|
+
if (!this.prev.has(v)) {
|
|
15
|
+
this.prev.set(v, queue[i])
|
|
16
|
+
queue.push(v)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
parentsOf(pid) {
|
|
23
|
+
const p = this.indi.get(pid)
|
|
24
|
+
if (p && this.fam.has(p.famc)) {
|
|
25
|
+
const f = this.fam.get(p.famc)
|
|
26
|
+
return [f.husb, f.wife]
|
|
27
|
+
}
|
|
28
|
+
return [null, null]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
childrenOf(pid) {
|
|
32
|
+
const kids = []
|
|
33
|
+
for (const fid of this.indi.get(pid)?.fams ?? []) {
|
|
34
|
+
const f = this.fam.get(fid)
|
|
35
|
+
if (f) kids.push(...f.chil)
|
|
36
|
+
}
|
|
37
|
+
return kids
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
neighbors(pid) {
|
|
41
|
+
return [...this.parentsOf(pid).filter(Boolean), ...this.childrenOf(pid)]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** [x, ..., primary], or null for the primary person and the unconnected */
|
|
45
|
+
relPath(x) {
|
|
46
|
+
if (x === this.primary || !this.prev.has(x)) return null
|
|
47
|
+
const chain = [x]
|
|
48
|
+
while (chain.at(-1) !== this.primary) chain.push(this.prev.get(chain.at(-1)))
|
|
49
|
+
return chain
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** direction of b relative to a */
|
|
53
|
+
hop(a, b) {
|
|
54
|
+
if (this.parentsOf(a).includes(b)) return '↑'
|
|
55
|
+
if (this.parentsOf(b).includes(a)) return '↓'
|
|
56
|
+
return '·'
|
|
57
|
+
}
|
|
58
|
+
}
|
package/lib/pii.mjs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Contact details recorded in the GEDCOM must never reach the built site.
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { esc } from './html.mjs'
|
|
5
|
+
import { CONTACT } from './redact.mjs'
|
|
6
|
+
|
|
7
|
+
const PII_TAGS = new Set(['ADDR', 'ADR1', 'POST', 'PHON', 'EMAIL'])
|
|
8
|
+
const TEXT_EXT = new Set(['.html', '.css', '.js', '.txt'])
|
|
9
|
+
|
|
10
|
+
const textFiles = dir => fs.readdirSync(dir, { recursive: true }).sort()
|
|
11
|
+
.filter(f => TEXT_EXT.has(path.extname(f)) && fs.statSync(path.join(dir, f)).isFile())
|
|
12
|
+
|
|
13
|
+
export function piiValues(gedText) {
|
|
14
|
+
const values = new Set()
|
|
15
|
+
for (const ln of gedText.split(/\r\n?|\n/)) {
|
|
16
|
+
const m = ln.match(/^\d+ (\w+) (.*\S)/)
|
|
17
|
+
if (m && PII_TAGS.has(m[1])) values.add(m[2].trim())
|
|
18
|
+
}
|
|
19
|
+
return [...values]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function findLeaks(values, dir) {
|
|
23
|
+
const leaks = []
|
|
24
|
+
for (const file of textFiles(dir)) {
|
|
25
|
+
const text = fs.readFileSync(path.join(dir, file), 'utf8')
|
|
26
|
+
for (const value of values) {
|
|
27
|
+
if (text.includes(value) || text.includes(esc(value))) leaks.push({ file, value })
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return leaks
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function findContactText(dir) {
|
|
34
|
+
return textFiles(dir).flatMap(file =>
|
|
35
|
+
[...fs.readFileSync(path.join(dir, file), 'utf8').matchAll(CONTACT)].map(m => ({ file, value: m[0] })))
|
|
36
|
+
}
|
package/lib/redact.mjs
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// PII stays in the GEDCOM; the site shows places to city level and no contact details.
|
|
2
|
+
|
|
3
|
+
const EMAIL = /[\w.%+-]+@[\w-]+(?:\.[\w-]+)+/g
|
|
4
|
+
const PHONE = /(?:\+?1[-. ])?\(?\b\d{3}\)?[-. ]\d{3}[-. ]\d{4}\b/g
|
|
5
|
+
const STREET = /#?\b\d{1,5}\s+(?:[A-Z][\w']*\.?\s+){1,3}?(?:St|Ave|Dr|Rd|Blvd|Ct|Ln|Way|Pl|Street|Avenue|Drive|Road|Lane|Trail)\b\.?/g
|
|
6
|
+
const BOX = /\b(?:(?:Rt|Route|RR)\.?\s?\d+,?\s+)?(?:HCRT|P\.?\s?O\.?\s?Box|Box)\s?\d+/g
|
|
7
|
+
const ZIP = /\b([A-Z]{2}) \d{5}(?:-\d{4})?\b/g
|
|
8
|
+
|
|
9
|
+
export const CONTACT = new RegExp([EMAIL, PHONE, STREET, BOX].map(r => r.source).join('|'), 'g')
|
|
10
|
+
|
|
11
|
+
export const redact = s => s
|
|
12
|
+
.replace(EMAIL, '[email]')
|
|
13
|
+
.replace(PHONE, '[phone]')
|
|
14
|
+
.replace(BOX, '[address]')
|
|
15
|
+
.replace(STREET, '[address]')
|
|
16
|
+
.replace(ZIP, '$1 [address]')
|
|
17
|
+
|
|
18
|
+
export function place(s) {
|
|
19
|
+
const t = s.replace(STREET, '').replace(BOX, '')
|
|
20
|
+
if (t === s && !/^\s*#?\d/.test(s)) return s
|
|
21
|
+
const segs = t.split(',')
|
|
22
|
+
if (!segs[0].trim() || /^\s*#?\d/.test(segs[0])) segs.shift()
|
|
23
|
+
return segs.join(',').replace(/\s{2,}/g, ' ').trim()
|
|
24
|
+
}
|
package/lib/render.mjs
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// HTML for the person pages and the index. Output is pinned by test/golden.test.mjs.
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { place, redact } from './redact.mjs'
|
|
4
|
+
import { esc } from './html.mjs'
|
|
5
|
+
import { yr, byBirth } from './dates.mjs'
|
|
6
|
+
import { hourglassLayout, hourglassSvg } from './hourglass.mjs'
|
|
7
|
+
|
|
8
|
+
export { esc, yr }
|
|
9
|
+
export const DEFAULT_TITLE = 'Family Tree'
|
|
10
|
+
export const IMG_EXT = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp'])
|
|
11
|
+
|
|
12
|
+
const known = s => ((s ?? '').trim() === '?' ? '' : s ?? '')
|
|
13
|
+
const restricted = e => (e.resn ?? '').toUpperCase().split(/[\s,]+/).some(t => t === 'PRIVACY' || t === 'CONFIDENTIAL')
|
|
14
|
+
|
|
15
|
+
export function life(p) {
|
|
16
|
+
const b = restricted(p.birt) ? '' : yr(p.birt.date), d = restricted(p.deat) ? '' : yr(p.deat.date)
|
|
17
|
+
return b || d ? `${b}–${d}` : ''
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const QUALIFIERS = { ABT: 'about', BEF: 'before', AFT: 'after', CAL: 'calculated', EST: 'estimated', FROM: 'from', TO: 'to' }
|
|
21
|
+
|
|
22
|
+
function readable(date) {
|
|
23
|
+
const range = date.match(/^(?:BET (.+) AND|FROM (.+) TO) (.+)$/)
|
|
24
|
+
if (range) return `${range[1] ?? range[2]} – ${range[3]}`
|
|
25
|
+
const [, q, rest] = date.match(/^(\w+) (.+)$/) ?? []
|
|
26
|
+
return QUALIFIERS[q] ? `${QUALIFIERS[q]} ${rest}` : date
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const yearPair = s => /^\d{4}\/\d{1,4}$/.test(s ?? '')
|
|
30
|
+
|
|
31
|
+
export function when(e) {
|
|
32
|
+
const date = e.date ? readable(e.date) : ''
|
|
33
|
+
if (!e.phrase || (date && yearPair(e.phrase))) return date
|
|
34
|
+
return date ? `${date} (${e.phrase})` : e.phrase
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const LABELS = {
|
|
38
|
+
BIRT: 'Born', CHR: 'Christened', BAPM: 'Baptized', DEAT: 'Died', BURI: 'Buried',
|
|
39
|
+
RESI: 'Residence', EMIG: 'Emigrated', OCCU: 'Occupation',
|
|
40
|
+
}
|
|
41
|
+
const ORDER = { BIRT: -3, CHR: -2, BAPM: -1, DEAT: 1, BURI: 2 }
|
|
42
|
+
|
|
43
|
+
export class Renderer {
|
|
44
|
+
constructor({ indi, fam, kin, copied, title = DEFAULT_TITLE, source = '' }) {
|
|
45
|
+
Object.assign(this, { indi, fam, kin, copied, title, source })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
page(title, body) {
|
|
49
|
+
return `<!doctype html><html lang=en><head><meta charset=utf-8>` +
|
|
50
|
+
`<meta name=viewport content='width=device-width,initial-scale=1'>` +
|
|
51
|
+
`<title>${esc(title)}</title><link rel=stylesheet href=style.css></head>` +
|
|
52
|
+
`<body><div class=wrap>${body}` +
|
|
53
|
+
`<footer>Generated ${this.source ? `from <code>${esc(this.source)}</code> ` : ''}by kinsite ` +
|
|
54
|
+
`· ${this.indi.size} individuals · ${this.fam.size} families</footer>` +
|
|
55
|
+
`</div></body></html>`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
plink(pid) {
|
|
59
|
+
const p = this.indi.get(pid)
|
|
60
|
+
if (!p) return '<span class=unknown>(unknown)</span>'
|
|
61
|
+
const l = life(p)
|
|
62
|
+
return `<a href="${pid}.html">${esc(p.name || '(unnamed)')}</a>` + (l ? ` <span class=yr>(${l})</span>` : '')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
evtHtml(title, e) {
|
|
66
|
+
const bits = [], val = known(e.val), plac = place(known(e.plac))
|
|
67
|
+
if (val && val !== 'Y') bits.push(esc(redact(val)))
|
|
68
|
+
if (when(e)) bits.push(esc(when(e)))
|
|
69
|
+
if (plac) bits.push(esc(plac.replace(/[, ]+$/, '')))
|
|
70
|
+
if (!bits.length) {
|
|
71
|
+
if (e.tag !== 'BIRT' && e.tag !== 'DEAT') return ''
|
|
72
|
+
bits.push('date unknown')
|
|
73
|
+
}
|
|
74
|
+
return `<div class="evt"><span class="lbl">${title}</span> ${bits.join(' · ')}</div>`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
eventsHtml(p) {
|
|
78
|
+
const label = e => (e.tag !== 'EVEN' ? LABELS[e.tag] : e.type === 'Living' ? 'Residence' : esc(e.type || 'Event'))
|
|
79
|
+
return p.events.filter(e => !restricted(e)).sort((a, b) => (ORDER[a.tag] ?? 0) - (ORDER[b.tag] ?? 0))
|
|
80
|
+
.map(e => this.evtHtml(label(e), e)).join('')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
marriageHtml(mar) {
|
|
84
|
+
const text = [when(mar), place(known(mar.plac))].filter(Boolean).join(' ').trim()
|
|
85
|
+
return text && !restricted(mar) ? ` <span class=yr>(m. ${esc(text)})</span>` : ''
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
chartSvg(pid) {
|
|
89
|
+
const label = id => {
|
|
90
|
+
const p = this.indi.get(id)
|
|
91
|
+
return [p.name || '(unnamed)', life(p)]
|
|
92
|
+
}
|
|
93
|
+
return hourglassSvg(hourglassLayout(pid, this), label)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
descendantsHtml(pid, depth) {
|
|
97
|
+
const kids = depth > 0 ? this.kin.childrenOf(pid).sort(byBirth(this.indi)) : []
|
|
98
|
+
if (!kids.length) return ''
|
|
99
|
+
return '<ul class=tree>' +
|
|
100
|
+
kids.map(c => `<li>${this.plink(c)}${this.descendantsHtml(c, depth - 1)}</li>`).join('') + '</ul>'
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
attachments(p) {
|
|
104
|
+
const pairs = p.media.filter(md => this.copied.has(md.file)).map(md => [md, this.copied.get(md.file)])
|
|
105
|
+
const isImg = ([md]) => IMG_EXT.has(path.extname(md.file).toLowerCase())
|
|
106
|
+
return { imgs: pairs.filter(isImg), docs: pairs.filter(x => !isImg(x)) }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
personPage(pid) {
|
|
110
|
+
const p = this.indi.get(pid), { kin, fam } = this
|
|
111
|
+
const b = ['<p class=back><a href="index.html">← All people</a></p>',
|
|
112
|
+
`<h1>${esc(p.name || '(unnamed)')}</h1>`]
|
|
113
|
+
const meta = [{ M: 'Male', F: 'Female' }[p.sex] ?? '', life(p)].filter(Boolean).join(' · ')
|
|
114
|
+
if (meta) b.push(`<p class=sub>${meta}</p>`)
|
|
115
|
+
|
|
116
|
+
if (pid !== kin.primary) {
|
|
117
|
+
const rp = kin.relPath(pid)
|
|
118
|
+
if (rp && rp.length > 1) {
|
|
119
|
+
let crumb = this.plink(rp[0])
|
|
120
|
+
for (let i = 0; i + 1 < rp.length; i++) {
|
|
121
|
+
crumb += ` <span class=hop>${kin.hop(rp[i], rp[i + 1])}</span> ` + this.plink(rp[i + 1])
|
|
122
|
+
}
|
|
123
|
+
b.push(`<div class=descent><span class=lbl>Path to ` +
|
|
124
|
+
`${esc(this.indi.get(kin.primary).name)} (${rp.length - 1} steps)</span>${crumb}</div>`)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const { imgs, docs } = this.attachments(p)
|
|
129
|
+
if (imgs.length) {
|
|
130
|
+
const cells = imgs.map(([md, src]) =>
|
|
131
|
+
`<figure><a href="${esc(src)}"><img src="${esc(src)}" alt="${esc(md.title)}"></a>` +
|
|
132
|
+
`<figcaption>${esc(md.title)}</figcaption></figure>`).join('')
|
|
133
|
+
b.push(`<div class="photos">${cells}</div>`)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const vit = this.eventsHtml(p)
|
|
137
|
+
if (vit) b.push(`<div class=card>${vit}</div>`)
|
|
138
|
+
|
|
139
|
+
b.push('<h2>Family</h2>' + this.chartSvg(pid))
|
|
140
|
+
|
|
141
|
+
const rel = []
|
|
142
|
+
const par = []
|
|
143
|
+
if (p.famc && fam.has(p.famc)) {
|
|
144
|
+
const f = fam.get(p.famc)
|
|
145
|
+
for (const who of [f.husb, f.wife]) if (who) par.push(this.plink(who))
|
|
146
|
+
}
|
|
147
|
+
if (par.length) rel.push('<h2>Parents</h2><ul class=rel>' + par.map(x => `<li>${x}</li>`).join('') + '</ul>')
|
|
148
|
+
for (const fid of p.fams) {
|
|
149
|
+
const f = fam.get(fid)
|
|
150
|
+
if (!f) continue
|
|
151
|
+
const spouse = f.husb === pid ? f.wife : f.husb
|
|
152
|
+
const sp = []
|
|
153
|
+
if (spouse) {
|
|
154
|
+
sp.push(`<li>${this.plink(spouse)}${this.marriageHtml(f.marr)}</li>`)
|
|
155
|
+
}
|
|
156
|
+
const kids = [...f.chil].sort(byBirth(this.indi)).map(c => `<li>${this.plink(c)}</li>`)
|
|
157
|
+
rel.push('<h2>Spouse & children</h2><ul class=rel>' + sp.join('') + kids.join('') + '</ul>')
|
|
158
|
+
}
|
|
159
|
+
if (rel.length) b.push(rel.join(''))
|
|
160
|
+
|
|
161
|
+
const sibs = p.famc ? (fam.get(p.famc)?.chil ?? []).filter(c => c !== pid).sort(byBirth(this.indi)) : []
|
|
162
|
+
if (sibs.length) b.push('<h2>Siblings</h2><ul class=rel>' + sibs.map(c => `<li>${this.plink(c)}</li>`).join('') + '</ul>')
|
|
163
|
+
|
|
164
|
+
const desc = this.descendantsHtml(pid, 3)
|
|
165
|
+
if (desc) b.push('<h2>Descendants</h2>' + desc)
|
|
166
|
+
|
|
167
|
+
if (docs.length) {
|
|
168
|
+
const links = docs.map(([md, src]) => `<a href="${esc(src)}">${esc(md.title || path.basename(src))}</a>`).join('')
|
|
169
|
+
b.push(`<h2>Documents</h2><div class=docs>${links}</div>`)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const notes = p.notes.filter(n => n.trim())
|
|
173
|
+
if (notes.length) b.push('<h2>Notes</h2>' + notes.map(n => `<div class=note>${esc(redact(n))}</div>`).join(''))
|
|
174
|
+
|
|
175
|
+
return { html: this.page(p.name || pid, b.join('')), withPhoto: imgs.length > 0 }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
indexPage() {
|
|
179
|
+
const lower = s => s.toLowerCase()
|
|
180
|
+
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0)
|
|
181
|
+
const people = [...this.indi.values()].sort((a, b) =>
|
|
182
|
+
cmp(lower(a.surn), lower(b.surn)) || cmp(lower(a.giv), lower(b.giv)) || cmp(a.id, b.id))
|
|
183
|
+
const hasPhoto = p => this.attachments(p).imgs.length > 0
|
|
184
|
+
const withphoto = people.filter(hasPhoto).length
|
|
185
|
+
const items = people.map(p => {
|
|
186
|
+
const disp = p.surn ? `${esc(p.surn)}, ${esc(p.giv)}` : esc(p.name || '(unnamed)')
|
|
187
|
+
const l = life(p)
|
|
188
|
+
const photo = hasPhoto(p)
|
|
189
|
+
return `<li data-n="${esc(p.name.toLowerCase())}"${photo ? ' data-photo' : ''}>` +
|
|
190
|
+
`<a href="${p.id}.html">${disp}</a>` + (l ? ` <span class=yr>${l}</span>` : '') +
|
|
191
|
+
(photo ? ' <span class=cam title="Has photos">📷</span>' : '') + '</li>'
|
|
192
|
+
})
|
|
193
|
+
const root = this.kin.primary ?? people[0]?.id ?? null
|
|
194
|
+
const hero = root ? `<h2>Family of ${esc(this.indi.get(root).name)}</h2>${this.chartSvg(root)}` : ''
|
|
195
|
+
const body = `<h1>${esc(this.title)}</h1>` +
|
|
196
|
+
`<p class=sub>${this.indi.size} individuals · ${this.fam.size} families ` +
|
|
197
|
+
`· ${withphoto} with photos</p>` +
|
|
198
|
+
hero +
|
|
199
|
+
'<h2>All people</h2>' +
|
|
200
|
+
'<input id=q placeholder="Filter by name…" autocomplete=off>' +
|
|
201
|
+
`<label class=only><input type=checkbox id=photos> Only people with photos (${withphoto})</label>` +
|
|
202
|
+
`<ul class=people id=list>${items.join('')}</ul>` +
|
|
203
|
+
"<script>var q=document.getElementById('q'),ph=document.getElementById('photos')," +
|
|
204
|
+
"L=document.getElementById('list').children;" +
|
|
205
|
+
"function f(){var v=q.value.toLowerCase();" +
|
|
206
|
+
'for(var i=0;i<L.length;i++){L[i].style.display=' +
|
|
207
|
+
"L[i].getAttribute('data-n').indexOf(v)>-1&&(!ph.checked||L[i].hasAttribute('data-photo'))?'':'none';}}" +
|
|
208
|
+
"q.addEventListener('input',f);ph.addEventListener('change',f);f();</script>"
|
|
209
|
+
return this.page(this.title, body)
|
|
210
|
+
}
|
|
211
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kinsite",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Static HTML family-tree site from a GEDCOM file",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/chrismo/kinsite.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/chrismo/kinsite#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/chrismo/kinsite/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"gedcom",
|
|
17
|
+
"genealogy",
|
|
18
|
+
"family-tree",
|
|
19
|
+
"static-site"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=22"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"lib/",
|
|
26
|
+
"build.mjs"
|
|
27
|
+
],
|
|
28
|
+
"bin": {
|
|
29
|
+
"kinsite": "build.mjs"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "node build.mjs",
|
|
33
|
+
"sample": "node build.mjs sample/kinsite.config.json",
|
|
34
|
+
"test": "node --test 'test/*.test.mjs'"
|
|
35
|
+
}
|
|
36
|
+
}
|