From a12be5bfd4f6fb64b9db4723e439b941a1969640 Mon Sep 17 00:00:00 2001 From: biss Date: Sun, 3 May 2026 07:28:13 +0800 Subject: [PATCH] =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/ISSUE_TEMPLATE/people-update.yml | 119 ++++++++++++++++ .github/workflows/people-update.yml | 49 +++++++ scripts/update-people-from-issue.mjs | 164 +++++++++++++++++++++++ 3 files changed, 332 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/people-update.yml create mode 100644 .github/workflows/people-update.yml create mode 100644 scripts/update-people-from-issue.mjs diff --git a/.github/ISSUE_TEMPLATE/people-update.yml b/.github/ISSUE_TEMPLATE/people-update.yml new file mode 100644 index 0000000..a263b13 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/people-update.yml @@ -0,0 +1,119 @@ +name: 更新「如今的我们」 +description: 新增或修改一位同学在「如今的我们」中的资料 +title: "people: " +labels: + - people-update +body: + - type: markdown + attributes: + value: | + 提交后,自动化会用 `slug` 作为唯一标识更新 `src/data/people.ts`。 + 如果这个 `slug` 不存在,会新增一条同学资料;如果已经存在,会修改对应资料。 + 可选字段留空时,修改已有资料会保留原值,新增资料会写为空字符串。 + - type: input + id: slug + attributes: + label: slug + description: URL 中使用的唯一标识,只建议使用小写字母、数字和短横线。 + placeholder: student-a + validations: + required: true + - type: input + id: initial + attributes: + label: initial + description: 卡片照片占位时显示的字母或简称。 + placeholder: A + validations: + required: true + - type: input + id: name + attributes: + label: name + placeholder: 同学 A + validations: + required: true + - type: input + id: photo + attributes: + label: photo + description: 近照路径,例如 /photos/student-a.jpg;可以先留空。 + placeholder: /photos/student-a.jpg + - type: input + id: location + attributes: + label: location + placeholder: 北京 + validations: + required: true + - type: input + id: school + attributes: + label: school + placeholder: 示例大学 + validations: + required: true + - type: input + id: direction + attributes: + label: direction + placeholder: 计算机科学 + validations: + required: true + - type: input + id: keywords + attributes: + label: keywords + description: 用中文逗号、英文逗号或顿号分隔。 + placeholder: 开始、独立、想念 + validations: + required: true + - type: textarea + id: text + attributes: + label: text + description: 卡片上的一句近况。 + placeholder: 这一年的关键词:开始、独立、想念。 + validations: + required: true + - type: textarea + id: currentStatus + attributes: + label: currentStatus + description: 个人页「近况」内容。 + validations: + required: true + - type: textarea + id: highlight + attributes: + label: highlight + description: 个人页「这一年的小事」内容。 + validations: + required: true + - type: textarea + id: toPastSelf + attributes: + label: toPastSelf + description: 个人页「想对高中时的自己说」内容。 + validations: + required: true + - type: textarea + id: messageToClass + attributes: + label: messageToClass + description: 个人页「想对大家说」内容。 + validations: + required: true + - type: textarea + id: favoriteMemory + attributes: + label: favoriteMemory + description: 个人页「最想带走的高中瞬间」内容。 + validations: + required: true + - type: input + id: contact + attributes: + label: contact + description: 微信、邮箱或其他联系方式;可以留空。 + placeholder: 可填写微信、邮箱或留空 diff --git a/.github/workflows/people-update.yml b/.github/workflows/people-update.yml new file mode 100644 index 0000000..40d34f7 --- /dev/null +++ b/.github/workflows/people-update.yml @@ -0,0 +1,49 @@ +name: Update People Data + +on: + issues: + types: + - opened + - edited + - labeled + +permissions: + contents: write + issues: write + +jobs: + update-people: + if: contains(github.event.issue.labels.*.name, 'people-update') || startsWith(github.event.issue.title, 'people:') + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Update src/data/people.ts + env: + ISSUE_BODY: ${{ github.event.issue.body }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + ISSUE_TITLE: ${{ github.event.issue.title }} + run: node scripts/update-people-from-issue.mjs + + - name: Commit changes + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: "chore: update people data from issue #${{ github.event.issue.number }}" + file_pattern: src/data/people.ts + + - name: Comment on issue + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: "已根据这个 issue 更新 `src/data/people.ts`。如果内容需要再调整,直接编辑 issue 表单即可再次触发。" + }); diff --git a/scripts/update-people-from-issue.mjs b/scripts/update-people-from-issue.mjs new file mode 100644 index 0000000..27356d9 --- /dev/null +++ b/scripts/update-people-from-issue.mjs @@ -0,0 +1,164 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { relative, resolve } from "node:path"; + +const peoplePath = resolve(process.env.PEOPLE_PATH ?? "src/data/people.ts"); +const issueBody = process.env.ISSUE_BODY ?? ""; + +const fields = [ + "slug", + "initial", + "name", + "photo", + "location", + "school", + "direction", + "keywords", + "text", + "currentStatus", + "highlight", + "toPastSelf", + "messageToClass", + "favoriteMemory", + "contact" +]; + +const requiredFields = [ + "slug", + "initial", + "name", + "location", + "school", + "direction", + "keywords", + "text", + "currentStatus", + "highlight", + "toPastSelf", + "messageToClass", + "favoriteMemory" +]; + +const sectionAliases = new Map(fields.map((field) => [field.toLowerCase(), field])); + +function normalizeValue(value) { + const trimmed = value.trim(); + return trimmed === "_No response_" ? "" : trimmed; +} + +function parseIssueForm(body) { + const result = {}; + const sectionPattern = /^###\s+(.+?)\s*$/gm; + const matches = [...body.matchAll(sectionPattern)]; + + for (let index = 0; index < matches.length; index += 1) { + const rawLabel = matches[index][1].trim().toLowerCase(); + const field = sectionAliases.get(rawLabel); + if (!field) continue; + + const start = matches[index].index + matches[index][0].length; + const end = index + 1 < matches.length ? matches[index + 1].index : body.length; + result[field] = normalizeValue(body.slice(start, end)); + } + + return result; +} + +function assertValidInput(input) { + for (const field of requiredFields) { + if (!input[field]) { + throw new Error(`Issue form is missing required field: ${field}`); + } + } + + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(input.slug)) { + throw new Error("slug must use lowercase letters, numbers, and single hyphens only."); + } +} + +function readPeopleModule(filePath) { + const source = readFileSync(filePath, "utf8"); + const executable = source + .replace("export const peopleIntro =", "const peopleIntro =") + .replace("export const people =", "const people ="); + + return Function(`${executable}; return { peopleIntro, people };`)(); +} + +function splitKeywords(value) { + return value + .split(/[、,,]/) + .map((keyword) => keyword.trim()) + .filter(Boolean); +} + +function buildPerson(input, existing = {}) { + const person = { ...existing }; + + for (const field of fields) { + if (field === "keywords") continue; + if (input[field] || !(field in person)) { + person[field] = input[field] ?? ""; + } + } + + if (input.keywords) { + person.keywords = splitKeywords(input.keywords); + } else if (!Array.isArray(person.keywords)) { + person.keywords = []; + } + + return person; +} + +function quote(value) { + return JSON.stringify(value); +} + +function formatPerson(person) { + return ` { + slug: ${quote(person.slug)}, + initial: ${quote(person.initial)}, + name: ${quote(person.name)}, + photo: ${quote(person.photo ?? "")}, + location: ${quote(person.location)}, + school: ${quote(person.school)}, + direction: ${quote(person.direction)}, + keywords: [${person.keywords.map(quote).join(", ")}], + text: ${quote(person.text)}, + currentStatus: ${quote(person.currentStatus)}, + highlight: ${quote(person.highlight)}, + toPastSelf: ${quote(person.toPastSelf)}, + messageToClass: ${quote(person.messageToClass)}, + favoriteMemory: ${quote(person.favoriteMemory)}, + contact: ${quote(person.contact ?? "")} + }`; +} + +function formatPeopleModule(peopleIntro, people) { + return `export const peopleIntro = { + title: ${quote(peopleIntro.title)}, + text: ${quote(peopleIntro.text)} +}; + +export const people = [ +${people.map(formatPerson).join(",\n")} +]; +`; +} + +const input = parseIssueForm(issueBody); +assertValidInput(input); + +const { peopleIntro, people } = readPeopleModule(peoplePath); +const existingIndex = people.findIndex((person) => person.slug === input.slug); + +if (existingIndex >= 0) { + people[existingIndex] = buildPerson(input, people[existingIndex]); + console.log(`Updated existing person: ${input.slug}`); +} else { + people.push(buildPerson(input)); + console.log(`Added new person: ${input.slug}`); +} + +writeFileSync(peoplePath, formatPeopleModule(peopleIntro, people), "utf8"); +console.log(`Wrote ${relative(process.cwd(), peoplePath)}`);