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 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
- # Temporary Holding Version
1
+ # kinsite
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
@@ -0,0 +1,2 @@
1
+ const ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#x27;' }
2
+ export const esc = s => (s || '').replace(/[&<>"']/g, c => ESCAPES[c])
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 '&uarr;'
55
+ if (this.parentsOf(b).includes(a)) return '&darr;'
56
+ return '&middot;'
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
+ `&middot; ${this.indi.size} individuals &middot; ${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(' &middot; ')}</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">&larr; 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(' &middot; ')
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 &amp; 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 &middot; ${this.fam.size} families ` +
197
+ `&middot; ${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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }