@markuxt/markuxt 0.1.15 → 0.1.16

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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Markuxt
2
2
 
3
3
  <div align="center">
4
- <img src="src/public/images/logo.png" alt="Markuxt" width="360" />
4
+ <img src="https://raw.githubusercontent.com/markuxt/markuxt/main/src/public/images/logo.png" alt="Markuxt" width="360" />
5
5
  <!-- <h1>Markuxt</h1> -->
6
6
  </div>
7
7
 
package/action.yml ADDED
@@ -0,0 +1,24 @@
1
+ name: 'Sync Publications from OpenAlex'
2
+ description: 'Fetches publications from OpenAlex based on member ORCIDs and syncs to content directory'
3
+
4
+ inputs:
5
+ ror_id:
6
+ description: 'ROR ID of the institution (e.g., https://ror.org/03y4dt428)'
7
+ required: true
8
+ contact_email:
9
+ description: 'Contact email for OpenAlex API requests'
10
+ required: true
11
+ content_dir:
12
+ description: 'Path to content directory (default: src)'
13
+ required: false
14
+ default: 'src'
15
+
16
+ outputs:
17
+ new_publications_count:
18
+ description: 'Number of new publication files added'
19
+ new_publications_files:
20
+ description: 'List of new publication file paths'
21
+
22
+ runs:
23
+ using: 'node20'
24
+ main: 'src/actions/sync-publications/index.ts'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markuxt/markuxt",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "A Markdown-first academic portal framework for laboratories, research groups, and knowledge communities, powered by Nuxt.",
5
5
  "author": "hnrobert",
6
6
  "license": "Apache-2.0",
@@ -14,6 +14,7 @@
14
14
  "access": "public"
15
15
  },
16
16
  "files": [
17
+ "action.yml",
17
18
  "nuxt.config.ts",
18
19
  "app.config.ts",
19
20
  "app.config.d.ts",
@@ -30,6 +31,7 @@
30
31
  "@icon-park/vue-next": "^1.4.2",
31
32
  "@nuxt/content": "^2.13.2",
32
33
  "@nuxtjs/i18n": "^10.4.0",
34
+ "glob": "^13.0.6",
33
35
  "katex": "0.16.47",
34
36
  "mermaid": "11.15.0",
35
37
  "nuxt": "^3.15.1",
@@ -38,7 +40,11 @@
38
40
  },
39
41
  "devDependencies": {
40
42
  "@types/node": "^25.2.3",
43
+ "tsx": "^4.19.0",
41
44
  "typescript": "^5.7.3",
42
45
  "vue-tsc": "^3.2.4"
46
+ },
47
+ "bin": {
48
+ "markuxt-sync-publications": "./src/actions/sync-publications/index.ts"
43
49
  }
44
50
  }
@@ -0,0 +1,488 @@
1
+ #!/usr/bin/env -S node --loader tsx
2
+
3
+ /**
4
+ * markuxt-sync-publications
5
+ *
6
+ * GitHub Action to sync publications from OpenAlex based on member ORCIDs.
7
+ * Fetches publications for all members with ORCID, deduplicates against existing
8
+ * content, and writes new markdown files to <content_dir>/publications/<year>/<openalex_id>/index.md
9
+ *
10
+ * Usage: Called via GitHub Action (see action.yml) or directly:
11
+ * ROR_ID="https://ror.org/..." CONTACT_EMAIL="..." CONTENT_DIR="src" tsx index.ts
12
+ */
13
+
14
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'
15
+ import { join } from 'path'
16
+ import { glob } from 'glob'
17
+
18
+ // Inputs from GitHub Actions (passed as environment variables)
19
+ const ROR_ID = process.env.INPUT_ROR_ID || ''
20
+ const CONTACT_EMAIL = process.env.INPUT_CONTACT_EMAIL || ''
21
+ const CONTENT_DIR = process.env.INPUT_CONTENT_DIR || 'src'
22
+ const GITHUB_OUTPUT = process.env.GITHUB_OUTPUT || ''
23
+
24
+ if (!ROR_ID) {
25
+ console.error('Error: INPUT_ROR_ID is required')
26
+ process.exit(1)
27
+ }
28
+
29
+ if (!CONTACT_EMAIL) {
30
+ console.error('Error: INPUT_CONTACT_EMAIL is required')
31
+ process.exit(1)
32
+ }
33
+
34
+ const OPENALEX_BASE = 'https://api.openalex.org'
35
+ const PUBLICATIONS_DIR = join(CONTENT_DIR, 'publications')
36
+ const MEMBERS_DIR = join(CONTENT_DIR, 'members')
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Types
40
+ // ---------------------------------------------------------------------------
41
+
42
+ interface ExistingPublication {
43
+ openalexId?: string
44
+ doi?: string
45
+ title?: string
46
+ year?: number
47
+ authors?: string[]
48
+ }
49
+
50
+ interface PendingPublication {
51
+ openalexId: string
52
+ title: string
53
+ authors: string[]
54
+ authorsOrcid: (string | null)[]
55
+ year: number
56
+ doi: string | null
57
+ venue: string | null
58
+ keywords: string[]
59
+ abstract: string | null
60
+ hidden: boolean
61
+ }
62
+
63
+ interface MemberInfo {
64
+ name: string
65
+ orcid: string
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // YAML / Markdown helpers
70
+ // ---------------------------------------------------------------------------
71
+
72
+ function parseYamlFrontmatter(content: string): Record<string, unknown> {
73
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
74
+ if (!match) return {}
75
+ const result: Record<string, unknown> = {}
76
+ const lines = match[1].split('\n')
77
+ let currentKey = ''
78
+ let currentList: string[] | null = null
79
+ for (const line of lines) {
80
+ const listItem = line.match(/^ - (.+)$/)
81
+ if (listItem && currentList !== null) {
82
+ currentList.push(listItem[1].trim().replace(/^["']|["']$/g, ''))
83
+ continue
84
+ }
85
+ if (currentList !== null) {
86
+ result[currentKey] = currentList
87
+ currentList = null
88
+ }
89
+ const kv = line.match(/^(\w[\w_]*)\s*:\s*(.*)$/)
90
+ if (!kv) continue
91
+ const [, key, val] = kv
92
+ const trimmed = val.trim()
93
+ if (trimmed === '') {
94
+ currentKey = key
95
+ currentList = []
96
+ } else {
97
+ result[key] = trimmed.replace(/^["']|["']$/g, '')
98
+ }
99
+ }
100
+ if (currentList !== null) result[currentKey] = currentList
101
+ return result
102
+ }
103
+
104
+ function yamlStr(value: string): string {
105
+ if (/[:#\[\]{}&*!,|>'"?%@`]/.test(value) || value.startsWith(' ') || value.endsWith(' ')) {
106
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
107
+ }
108
+ return value
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Deduplication helpers
113
+ // ---------------------------------------------------------------------------
114
+
115
+ const STOP_WORDS = new Set([
116
+ 'a','an','the','of','in','on','for','to','and','or','with','by','from',
117
+ 'at','is','are','was','were','be','been','being','that','this','these',
118
+ 'those','it','its'
119
+ ])
120
+
121
+ function tokenize(title: string): Set<string> {
122
+ return new Set(
123
+ title.toLowerCase()
124
+ .replace(/[^a-z0-9\s]/g, ' ')
125
+ .split(/\s+/)
126
+ .filter(w => w.length > 1 && !STOP_WORDS.has(w))
127
+ )
128
+ }
129
+
130
+ function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
131
+ if (a.size === 0 && b.size === 0) return 1
132
+ let intersection = 0
133
+ for (const w of a) if (b.has(w)) intersection++
134
+ const union = a.size + b.size - intersection
135
+ return union === 0 ? 0 : intersection / union
136
+ }
137
+
138
+ function authorOverlap(a: string[], b: string[]): number {
139
+ if (!a.length || !b.length) return 0
140
+ const normalize = (name: string) => name.toLowerCase().replace(/[^a-z]/g, '')
141
+ const setA = new Set(a.map(normalize))
142
+ const setB = new Set(b.map(normalize))
143
+ let intersection = 0
144
+ for (const n of setA) if (setB.has(n)) intersection++
145
+ const union = setA.size + setB.size - intersection
146
+ return union === 0 ? 0 : intersection / union
147
+ }
148
+
149
+ function isDuplicate(
150
+ candidate: { title: string; year: number; authors: string[] },
151
+ existing: { title: string; year: number; authors: string[] }
152
+ ): boolean {
153
+ if (candidate.title.toLowerCase().trim() === existing.title.toLowerCase().trim()) return true
154
+ if (Math.abs(candidate.year - existing.year) > 1) return false
155
+ const titleSim = jaccardSimilarity(tokenize(candidate.title), tokenize(existing.title))
156
+ const authorSim = authorOverlap(candidate.authors, existing.authors)
157
+ return titleSim >= 0.85 && authorSim >= 0.5
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Abstract reconstruction from inverted index
162
+ // ---------------------------------------------------------------------------
163
+
164
+ function reconstructAbstract(invertedIndex: Record<string, number[]> | null): string | null {
165
+ if (!invertedIndex) return null
166
+ const entries: [string, number][] = []
167
+ for (const [word, positions] of Object.entries(invertedIndex)) {
168
+ for (const pos of positions) entries.push([word, pos])
169
+ }
170
+ entries.sort((a, b) => a[1] - b[1])
171
+ return entries.map(e => e[0]).join(' ')
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Author name formatting: OpenAlex display_name → "LastName, FirstName"
176
+ // ---------------------------------------------------------------------------
177
+
178
+ function formatAuthorName(displayName: string): string {
179
+ const parts = displayName.trim().split(/\s+/)
180
+ if (parts.length === 1) return parts[0]
181
+ const last = parts[parts.length - 1]
182
+ const first = parts.slice(0, -1).join(' ')
183
+ return `${last}, ${first}`
184
+ }
185
+
186
+ function extractOrcidId(orcidUrl: string | null): string | null {
187
+ if (!orcidUrl) return null
188
+ const m = orcidUrl.match(/(\d{4}-\d{4}-\d{4}-\d{3}[\dX])/)
189
+ return m ? m[1] : null
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Markdown file builder
194
+ // ---------------------------------------------------------------------------
195
+
196
+ function buildMarkdown(pub: PendingPublication): string {
197
+ const lines: string[] = ['---', `_hidden: ${pub.hidden}`]
198
+ lines.push(`title: ${yamlStr(pub.title)}`)
199
+ lines.push('authors:')
200
+ for (const a of pub.authors) lines.push(` - ${yamlStr(a)}`)
201
+ lines.push('authors_orcid:')
202
+ for (const o of pub.authorsOrcid) lines.push(` - ${o ?? 'null'}`)
203
+ lines.push(`year: ${pub.year}`)
204
+ lines.push(`doi: ${pub.doi ? yamlStr(pub.doi) : ''}`)
205
+ lines.push(`openalex_id: ${pub.openalexId}`)
206
+ lines.push(`venue: ${pub.venue ? yamlStr(pub.venue) : ''}`)
207
+ if (pub.keywords.length) {
208
+ lines.push('keywords:')
209
+ for (const k of pub.keywords) lines.push(` - ${yamlStr(k)}`)
210
+ } else {
211
+ lines.push('keywords: []')
212
+ }
213
+ lines.push('---', '')
214
+ if (pub.abstract) lines.push(pub.abstract, '')
215
+ return lines.join('\n')
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // OpenAlex API helpers
220
+ // ---------------------------------------------------------------------------
221
+
222
+ async function oaFetch(path: string): Promise<unknown> {
223
+ const sep = path.includes('?') ? '&' : '?'
224
+ const url = `${OPENALEX_BASE}${path}${sep}mailto=${encodeURIComponent(CONTACT_EMAIL)}`
225
+ const res = await fetch(url, {
226
+ headers: { 'User-Agent': `markuxt-sync-publications/1.0 (mailto:${CONTACT_EMAIL})` }
227
+ })
228
+ if (!res.ok) throw new Error(`OpenAlex ${res.status}: ${url}`)
229
+ return res.json()
230
+ }
231
+
232
+ async function getInstitutionId(rorId: string): Promise<string> {
233
+ const data = await oaFetch(
234
+ `/institutions?filter=ror:${encodeURIComponent(rorId)}&select=id`
235
+ ) as { results: { id: string }[] }
236
+ if (!data.results?.length) throw new Error(`Institution not found for ROR: ${rorId}`)
237
+ return data.results[0].id
238
+ }
239
+
240
+ async function getAuthorId(orcid: string): Promise<string | null> {
241
+ const data = await oaFetch(
242
+ `/authors?filter=orcid:${encodeURIComponent(orcid)}&select=id`
243
+ ) as { results: { id: string }[] }
244
+ return data.results?.[0]?.id ?? null
245
+ }
246
+
247
+ async function getWorksForAuthor(authorId: string, institutionId: string): Promise<unknown[]> {
248
+ const works: unknown[] = []
249
+ let cursor = '*'
250
+ const fields = 'id,title,authorships,publication_year,doi,primary_location,keywords,abstract_inverted_index'
251
+ while (true) {
252
+ const data = await oaFetch(
253
+ `/works?filter=author.id:${encodeURIComponent(authorId)},institution.id:${encodeURIComponent(institutionId)}&per_page=200&cursor=${cursor}&select=${fields}`
254
+ ) as { results: unknown[]; meta: { next_cursor: string | null } }
255
+ works.push(...data.results)
256
+ if (!data.meta?.next_cursor) break
257
+ cursor = data.meta.next_cursor
258
+ }
259
+ return works
260
+ }
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // Scan existing publications
264
+ // ---------------------------------------------------------------------------
265
+
266
+ async function scanExistingPublications(): Promise<ExistingPublication[]> {
267
+ const files = await glob('**/*.md', { cwd: PUBLICATIONS_DIR, absolute: true })
268
+ const existing: ExistingPublication[] = []
269
+ for (const file of files) {
270
+ const content = readFileSync(file, 'utf-8')
271
+ const fm = parseYamlFrontmatter(content)
272
+ if (fm._hidden === 'true' || fm._hidden === true) continue
273
+ const openalexId = typeof fm.openalex_id === 'string'
274
+ ? fm.openalex_id.replace(/^W/, '') : undefined
275
+ const doi = typeof fm.doi === 'string' && fm.doi ? fm.doi : undefined
276
+ const title = typeof fm.title === 'string' ? fm.title : undefined
277
+ const year = typeof fm.year === 'string'
278
+ ? parseInt(fm.year) : (typeof fm.year === 'number' ? fm.year : undefined)
279
+ const authors = Array.isArray(fm.authors) ? fm.authors as string[] : undefined
280
+ existing.push({ openalexId, doi, title, year, authors })
281
+ }
282
+ return existing
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Scan members with ORCID
287
+ // ---------------------------------------------------------------------------
288
+
289
+ async function scanMembersWithOrcid(): Promise<MemberInfo[]> {
290
+ const files = await glob('**/*.md', { cwd: MEMBERS_DIR, absolute: true })
291
+ const members: MemberInfo[] = []
292
+ for (const file of files) {
293
+ const content = readFileSync(file, 'utf-8')
294
+ const fm = parseYamlFrontmatter(content)
295
+ if (fm._hidden === 'true' || fm._hidden === true) continue
296
+ if (typeof fm.orcid === 'string' && fm.orcid.trim()) {
297
+ members.push({ name: String(fm.name ?? 'Unknown'), orcid: fm.orcid.trim() })
298
+ }
299
+ }
300
+ return members
301
+ }
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // Parse OpenAlex work → PendingPublication
305
+ // ---------------------------------------------------------------------------
306
+
307
+ function parseWork(work: Record<string, unknown>): PendingPublication | null {
308
+ const rawId = typeof work.id === 'string'
309
+ ? work.id.replace('https://openalex.org/', '') : null
310
+ if (!rawId) return null
311
+
312
+ const title = typeof work.title === 'string' ? work.title.trim() : null
313
+ if (!title) return null
314
+
315
+ const year = typeof work.publication_year === 'number' ? work.publication_year : null
316
+ if (!year) return null
317
+
318
+ const authorships = Array.isArray(work.authorships)
319
+ ? work.authorships as Record<string, unknown>[] : []
320
+
321
+ const authors = authorships.map(a => {
322
+ const author = a.author as Record<string, unknown> | undefined
323
+ return author?.display_name ? formatAuthorName(String(author.display_name)) : null
324
+ }).filter((n): n is string => n !== null)
325
+
326
+ const authorsOrcid = authorships.map(a => {
327
+ const author = a.author as Record<string, unknown> | undefined
328
+ return extractOrcidId(author?.orcid ? String(author.orcid) : null)
329
+ })
330
+
331
+ const doiRaw = typeof work.doi === 'string' ? work.doi : null
332
+ const doi = doiRaw
333
+ ? (doiRaw.startsWith('http') ? doiRaw : `https://doi.org/${doiRaw}`) : null
334
+
335
+ const primaryLocation = work.primary_location as Record<string, unknown> | undefined
336
+ const source = primaryLocation?.source as Record<string, unknown> | undefined
337
+ const venue = source?.display_name ? String(source.display_name) : null
338
+
339
+ const keywordsRaw = Array.isArray(work.keywords)
340
+ ? work.keywords as Record<string, unknown>[] : []
341
+ const keywords = keywordsRaw.map(k => String(k.display_name ?? '')).filter(Boolean)
342
+
343
+ const abstract = reconstructAbstract(
344
+ work.abstract_inverted_index as Record<string, number[]> | null
345
+ )
346
+
347
+ return { openalexId: rawId, title, authors, authorsOrcid, year, doi, venue, keywords, abstract, hidden: false }
348
+ }
349
+
350
+ // ---------------------------------------------------------------------------
351
+ // Set GitHub Actions output
352
+ // ---------------------------------------------------------------------------
353
+
354
+ function setOutput(name: string, value: string): void {
355
+ if (GITHUB_OUTPUT) {
356
+ appendFileSync(GITHUB_OUTPUT, `${name}=${value}\n`)
357
+ }
358
+ console.log(`::set-output name=${name}::${value}`)
359
+ }
360
+
361
+ function appendFileSync(file: string, data: string): void {
362
+ const fd = require('fs').openSync(file, 'a')
363
+ require('fs').writeSync(fd, data)
364
+ require('fs').closeSync(fd)
365
+ }
366
+
367
+ // ---------------------------------------------------------------------------
368
+ // Main
369
+ // ---------------------------------------------------------------------------
370
+
371
+ async function main() {
372
+ console.log(`[markuxt-sync-publications] Starting...`)
373
+ console.log(`[markuxt-sync-publications] ROR ID: ${ROR_ID}`)
374
+ console.log(`[markuxt-sync-publications] Content dir: ${CONTENT_DIR}`)
375
+
376
+ // 1. Resolve institution OpenAlex ID
377
+ const institutionId = await getInstitutionId(ROR_ID)
378
+ console.log(`[markuxt-sync-publications] Institution ID: ${institutionId}`)
379
+
380
+ // 2. Scan existing publications
381
+ const existing = await scanExistingPublications()
382
+ const existingOpenalexIds = new Set(
383
+ existing.map(p => p.openalexId).filter((id): id is string => !!id)
384
+ )
385
+ const existingDois = new Set(
386
+ existing
387
+ .map(p => p.doi?.toLowerCase().replace(/https?:\/\/doi\.org\//i, ''))
388
+ .filter((d): d is string => !!d)
389
+ )
390
+ console.log(`[markuxt-sync-publications] Found ${existing.length} existing publications`)
391
+
392
+ // 3. Scan members with ORCID
393
+ const members = await scanMembersWithOrcid()
394
+ console.log(`[markuxt-sync-publications] Found ${members.length} members with ORCID`)
395
+
396
+ // 4. Fetch works from OpenAlex for each member
397
+ const allWorks = new Map<string, PendingPublication>()
398
+ for (const member of members) {
399
+ console.log(`[markuxt-sync-publications] Processing ${member.name} (${member.orcid})...`)
400
+ const authorId = await getAuthorId(member.orcid)
401
+ if (!authorId) {
402
+ console.warn(` → Not found on OpenAlex: ${member.orcid}`)
403
+ continue
404
+ }
405
+ console.log(` → Author ID: ${authorId}`)
406
+ const works = await getWorksForAuthor(authorId, institutionId)
407
+ console.log(` → ${works.length} works`)
408
+ for (const w of works) {
409
+ const pub = parseWork(w as Record<string, unknown>)
410
+ if (!pub) continue
411
+ if (!allWorks.has(pub.openalexId)) allWorks.set(pub.openalexId, pub)
412
+ }
413
+ }
414
+ console.log(`[markuxt-sync-publications] Total unique works from OpenAlex: ${allWorks.size}`)
415
+
416
+ // 5. Filter out already-existing works
417
+ const pending: PendingPublication[] = []
418
+ for (const pub of allWorks.values()) {
419
+ const shortId = pub.openalexId.replace(/^W/, '')
420
+ if (existingOpenalexIds.has(shortId)) continue
421
+ const doiKey = pub.doi?.toLowerCase().replace(/https?:\/\/doi\.org\//i, '')
422
+ if (doiKey && existingDois.has(doiKey)) continue
423
+ const dupOfExisting = existing.some(e =>
424
+ e.title && e.year != null &&
425
+ isDuplicate(
426
+ { title: pub.title, year: pub.year, authors: pub.authors },
427
+ { title: e.title, year: e.year, authors: e.authors ?? [] }
428
+ )
429
+ )
430
+ if (dupOfExisting) continue
431
+ pending.push(pub)
432
+ }
433
+ console.log(`[markuxt-sync-publications] After dedup vs existing: ${pending.length} to add`)
434
+
435
+ // 6. Dedup within pending list, keep newest per group
436
+ const toWrite: PendingPublication[] = []
437
+ const consumed = new Set<number>()
438
+ for (let i = 0; i < pending.length; i++) {
439
+ if (consumed.has(i)) continue
440
+ const group: number[] = [i]
441
+ for (let j = i + 1; j < pending.length; j++) {
442
+ if (consumed.has(j)) continue
443
+ const a = pending[i], b = pending[j]
444
+ const sameDoi = !!(a.doi && b.doi && a.doi.toLowerCase() === b.doi.toLowerCase())
445
+ const sameTitle = a.title.toLowerCase().trim() === b.title.toLowerCase().trim()
446
+ const similar = isDuplicate(
447
+ { title: a.title, year: a.year, authors: a.authors },
448
+ { title: b.title, year: b.year, authors: b.authors }
449
+ )
450
+ if (sameDoi || sameTitle || similar) {
451
+ group.push(j)
452
+ consumed.add(j)
453
+ }
454
+ }
455
+ consumed.add(i)
456
+ // Sort group: newest first; hide all but the first
457
+ group.sort((x, y) => pending[y].year - pending[x].year)
458
+ for (let k = 0; k < group.length; k++) {
459
+ toWrite.push({ ...pending[group[k]], hidden: k > 0 })
460
+ }
461
+ }
462
+
463
+ const visible = toWrite.filter(p => !p.hidden).length
464
+ const hidden = toWrite.filter(p => p.hidden).length
465
+ console.log(`[markuxt-sync-publications] Writing ${toWrite.length} files (${visible} visible, ${hidden} hidden)`)
466
+
467
+ // 7. Write markdown files
468
+ const newFiles: string[] = []
469
+ for (const pub of toWrite) {
470
+ const dir = join(PUBLICATIONS_DIR, String(pub.year), pub.openalexId)
471
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
472
+ const filePath = join(dir, 'index.md')
473
+ writeFileSync(filePath, buildMarkdown(pub), 'utf-8')
474
+ console.log(` [${pub.hidden ? 'hidden' : 'visible'}] ${filePath}`)
475
+ newFiles.push(filePath)
476
+ }
477
+
478
+ // 8. Set GitHub Actions outputs
479
+ setOutput('count', String(newFiles.length))
480
+ setOutput('files', newFiles.join('\n'))
481
+
482
+ console.log(`[markuxt-sync-publications] Done. Added ${newFiles.length} publication files.`)
483
+ }
484
+
485
+ main().catch(err => {
486
+ console.error('[markuxt-sync-publications] Fatal:', err)
487
+ process.exit(1)
488
+ })
@@ -29,6 +29,17 @@
29
29
  >
30
30
  <Google class="icon-inline" theme="outline" :size="20" fill="currentColor" :stroke-width="2.5" />
31
31
  </a>
32
+ <a
33
+ v-if="member.orcid"
34
+ :href="`https://orcid.org/${member.orcid}`"
35
+ target="_blank"
36
+ rel="noopener"
37
+ class="member-card__action"
38
+ :aria-label="t('members.orcid')"
39
+ @click.stop
40
+ >
41
+ <Orcid class="icon-inline" theme="outline" :size="20" fill="currentColor" :stroke-width="2.5" />
42
+ </a>
32
43
  </div>
33
44
  </div>
34
45
  <div class="member-card__content">
@@ -45,6 +56,7 @@
45
56
  import { computed } from 'vue'
46
57
  import Mail from '@icon-park/vue-next/es/icons/Mail'
47
58
  import Google from '@icon-park/vue-next/es/icons/Google'
59
+ import Orcid from '@icon-park/vue-next/es/icons/IdCardH'
48
60
 
49
61
  interface Member {
50
62
  name: string
@@ -52,6 +64,7 @@ interface Member {
52
64
  title?: string
53
65
  email?: string
54
66
  scholar?: string
67
+ orcid?: string
55
68
  image?: string
56
69
  interests?: string[]
57
70
  category?: string