docs
/
AI skills

Community setup

A members' community on the business's own site, with pages (groups, clubs, brands, events), who can join, who can post and how posts are moderated. Covers owners and moderators, announcements, badges, group chats and meetups, members invited or approved, and reports handled (warning, ban, removing the post). It lists every notice and email each step sends, and what doesn't work yet.

Appmint Community is a members' social space on the business's own website. A page (community_page) is one community: a club, a customer group, a brand, a venue or an event. Each page has members, a feed of posts with comments and reactions, stories, announcements, group chats and meetups. Connections, direct messages, follows, bookmarks and badges belong to the person, not to one page.

Members are customers signed in on the site. Everything is keyed by their email. Staff run it from Studio's Community app (/community). The API has two parts:

  • client/community/…: what members do (community/community-client.controller.ts). With call_appengine these calls act as the signed-in staff person, by their email.
  • community/…: connections, messages, meetings, blocks, plus the staff-only report review (community/community.controller.ts).

Studio's tabs save records with the ordinary repository create and update. The engine reacts to those saves: approvals, role changes, invitations, moderation notices and bans. So repo_create and repo_update do what Studio does. update-partial doesn't: it fires none of those reactions.

Related pages:

Ask first

  • What is the community, and who is it for? Its name, a one-line description, a cover picture and logo, and its type: group, brand, topic, venue, organization, personal or other. Event pages make themselves (step 2).
  • Who can see it? Anyone, including people who aren't signed in (public). Only members, while outsiders see a card and can ask to join (private). Or hidden from everyone who isn't a member (invite only).
  • Who can join? Anyone, people approved by an admin, or people invited only.
  • Who can post? Anyone signed in, members, or admins only. Are posts held for approval, or held only when someone reports them?
  • Who runs it? The email of the owner, and of any admins and moderators. These must be the emails they sign in to the site with.
  • Which parts are on: stories, group chat, announcements.
  • The address: /community, or another page name on the site.
  • Who to invite now, if anyone. Each invitation is an email.

Steps

1. Website: switch on the Community feature

The members' side is a built-in site page (base-app components/community/CommunityRoute.tsx). It sits inside the site's header and footer. It reads ?page=<slug or id> to open one community and ?tab= (feed, members, groups, meetups, messages). Without ?page= it opens the member's busiest community, else the busiest one on the site.

It answers only while the site's community feature is on. With the feature off, /community is a 404 (tested on the local site). To switch it on, add a row to site.data.features:

{ "feature": "community", "enabled": true, "page": "community" }

page is optional. It moves the address to /<page>. How to write the features list without losing the site's other settings, and how addresses are claimed: Site page routing. In Studio: Site Config › Features › Community.

Stop: switching it on publishes a page on the live site. Say the address and wait for yes.

Invitation emails link to the default site's host name and the community row's page (else community): https://<host>/community?page=<slug> (community-page.service.ts communityUrl 249–257). Tested: the link was http://phasite-phazzer.localhost:3200/community?page=skilltest-community-runners, which was a 404 because the feature was off. Switch the feature on before anyone is invited.

Hands on: the community's address. Check: repo_find_one on the site shows the community row. Opening the address shows the community, or "No community pages yet." when there are none.

2. Community › Pages: the page

The way that also makes the owner: call_appengine POST /client/community/pages. The signed-in person becomes the page's owner (a member with role owner), and the slug is made from the title. Tested: a staff sign-in made james@… the owner, and could then post and announce.

{
  "title": "Saturday Runners",
  "type": "group",
  "shortDescription": "Easy 5k every Saturday at 8",
  "description": "<p>…</p>",
  "coverImage": "https://…/cover.jpg",
  "logo": "https://…/logo.png",
  "visibility": "private",
  "joinPolicy": "approval_required",
  "postingPolicy": "members",
  "moderationLevel": "flagged_only",
  "status": "active",
  "features": { "storiesEnabled": true, "chatEnabled": true, "announcementsEnabled": true }
}

Send status: "active". This route saves no status by default. A page without a status opens by its link, but the page directory (GET /client/community/pages) lists only active and suspended pages. So it is missing from the site's community switcher and from EventOxygen's Explore. Tested: the page was missing from the directory until its status was set (community-page.service.ts 77–89, 216).

In Studio (Community › Pages › Add Page): Studio's form starts with status: active, visibility: public, joinPolicy: open, postingPolicy: members, moderationLevel: none, and chat off. It makes no owner. Tested: a page saved this way had no members. Add the owner in step 3.

The rules:

FieldValuesWhat it does (tested)
titlerequiredAlso the record's name, so it must be unique. A second page with the same title: "A community_page with name "…" already exists."
slugmade from the title?page=<slug> on the site.
statusactive, suspended, archived (Studio), also draft, removedsuspended: everyone can read, but posting, comments and reactions are refused ("This community is paused: posting is closed for now"). archived, removed and draft are 404 to members.
visibilitypublic (default), private, invite_onlypublic: the feed, members and announcements are readable by anyone, even signed out (tested). private: outsiders get only the page's card (title, description, pictures, member count, join policy) and "Page not found" for its feed (tested). invite_only: a 404 for everyone who isn't a member.
joinPolicyopen (default), approval_required, invite_onlyopen: joining makes them a member. approval_required: they wait as pending until approved (step 3). invite_only: joining is refused with "An invitation is required".
postingPolicymembers (default), anyone, admins_onlyRefusals: "Join the community to post", "Only page admins post here". Guests can comment and react but not post. Comments and reactions also need membership, unless anyone ("Join the community to take part").
moderationLevelnone (default), flagged_only, pre_approvepre_approve: posts and stories by members wait as pending until a moderator approves them. Owners, admins and moderators skip it. flagged_only: a reported post is held (step 5).
featuresstoriesEnabled, chatEnabled, announcementsEnabled, plus feedEnabled and mediaGalleryEnabledOnly an explicit false switches a part off. Stories refused: "Stories are switched off for this community". Group chats refused: "Group chat is switched off for this community" (tested). With announcements off, the site hides them. feedEnabled and mediaGalleryEnabled aren't read anywhere.
category, tags, images, websitefreeShown on the page.
linkedEntity, linkedEntityTypeTies the page to an event. Set automatically.

The page's owner or an admin changes it later with PUT /client/community/pages/<id>. Only these fields are taken: title, slug, type, description, shortDescription, category, tags, status, coverImage, logo, images, visibility, joinPolicy, postingPolicy, moderationLevel, features, website. Anything else is refused: "No supported page fields supplied" (tested). A non-admin is refused with "Page administrator required" (tested). Staff can edit any page in Studio, or with repo_update on community_page.

Every event has its own page. Creating an event makes an event page for it: active, public, open to join, members post (community-page.service.ts 40–60). Each ticket issued adds its holder as a member, with no email sent. Don't make a second page for an event. Find it with repo_find on community_page, { "data.linkedEntity": "<event sk>" }. See Event tickets and check-in.

Hands on: the page's sk and slug. Check: GET /client/community/pages?limit=50 lists it. Studio's Community › Pages shows it with its visibility and status. On the site, /<community page>?page=<slug> opens it.

3. Community › Pages › Members: owner, staff and members

The Members section is in the page's Edit Page drawer (it shows once the page is saved).

Roles are owner, admin, moderator, member, guest:

WhoCan
owner, adminedit the page, announce, invite, remove members, approve
owner, admin, moderatorpost under "admins only", skip pre-approval, open group chats, schedule meetups
memberpost (by the posting policy), comment, react, join group chats they are added to
guestread, comment and react; not post

Membership status is active, pending (asked to join), invited, banned or left. Only active counts.

Add the owner or staff to a page made in Studio. repo_create on community_page_member:

{ "page": "<page sk>", "email": "[email protected]", "role": "owner", "status": "active",
  "source": "manual", "name": "<page sk>:[email protected]", "joinedAt": "<now>", "notificationPreference": "all" }

This sends nothing. The page's member count isn't recounted on a create (tested: it stayed one short). It catches up at the next join, leave or approval.

Make someone an admin or moderator. They must be an active member. On screen: their role picker in the page's Members. By API: repo_update on their member record with { "role": "moderator" }. They get the notice "You're now a moderator of …".

Approve or decline a join request. On screen: Members › Requests, then Approve or Decline. By API, approve with repo_update { "status": "active", "joinedAt": "<now>" }. Tested: they got "You're in! Your request to join … was approved", and the member count went up. Decline deletes the request record.

Invite. On screen: Members › Invite, then pick a customer. By API: POST /client/community/pages/<id>/invite { "email" }, as an owner or admin. Either way the member record is saved as invited, and the person gets the community-invitation email and an in-app notice. Tested: one email, subject "You're invited to join ", linking …/community?page=<slug>. They accept by pressing Join on the page, which also works when the page is invite_only (community-page.service.ts 268–292).

Stop before inviting. Each invitation emails a real person. Show the list and wait for yes.

Remove or ban. A member leaves with POST …/pages/<id>/leave. An owner or admin removes someone with the same route. On screen, Ban sets status: banned. A banned person can't rejoin ("Membership is banned"). Restore sets them back to active.

Hands on: who is owner, admin and moderator. Check: GET /client/community/pages/<id>/members (?role=) lists the active members with name and photo. Studio's Members tabs show Members, Requests, Invited and Banned.

4. Community › Announcements: news for the members

As a page owner or admin: POST /client/community/pages/<id>/announcements. This is published at once. In Studio: Community › Announcements › New Announcement, which starts as Draft.

FieldValues
title, content (rich text), media
prioritynormal (default), important, urgent. Listed pinned first, then by priority, then newest.
targetAudienceall (default), admins (owners and admins), moderators (owners, admins, moderators)
targetRolesAny roles. When set, they decide who sees it, and targetAudience is ignored.
pinnedThe site shows a pinned or urgent one as a banner over the feed.
expiresAtHidden after this time.
statusdraft, published, archived. Members see only published.

Stop before publishing. Publishing (on create, or a draft saved as published) gives every active member it targets an in-app notice. On a public page, anyone can read it, even signed out. Tested: "Important: Route change — Saturday Runners" reached the member, the author got no notice, and an admins announcement was hidden from a plain member. Show the text and audience, and wait for yes. An announcement is announced once. Later edits don't notify again.

Check: GET /client/community/pages/<id>/announcements as a member lists it.

5. Community › Feed and Moderation: posts and reports

Members post from the site (POST /client/community/posts with page, content, media, contentType text/image/video/link/poll, poll). #tags in the text become hashtags. A post on a page is visibility: page. That means "on this page", not "members only": on a public page it is readable signed out. A page post can't be shared to the global feed ("This post cannot be shared publicly", tested).

Staff actions (Community › Feed, or repo_update on community_post):

ActionWriteWhat happens (tested)
Approve a held poststatus: active (was pending)The author gets "Your post in … was approved and is live".
Removestatus: removedIt leaves every feed. The author gets "A moderator removed your post in …".
Restorestatus: activeBack in the feed. No notice.
Pin / unpinpinned: true/falsePinned posts lead the first page of the feed.
Flagstatus: flaggedHidden from others on a flagged_only or pre_approve page. Still visible on an unmoderated page.

Reports. A member reports another member with POST /client/community/reports { reportedId, reason, details, context: { postId?, page? } }. reason is spam, harassment, inappropriate, unwanted_contact or other. reportedId is the customer's sk or email. Or they block someone and report in one step: POST /client/community/blocks with report: true. If the report names a post on a flagged_only or pre_approve page, that post is held as flagged at once. Tested: it left other people's feeds, and its author still saw it.

Staff see pending reports in Community › Moderation, or call_appengine GET /community/reports (staff only). The decisions:

On screenWhat is savedWhat happens (tested)
Dismissreport.status: dismissed, report.action: noneNothing else.
Send Warningreport.status: action_taken, report.action: warningThe member gets "A moderator of reviewed a report about your activity. Please follow the community guidelines."
Ban Memberreport.action: ban. Studio also removes the reported post.The member is banned on the report's page, else on the reported post's page, else on every page they belong to. They get "You've been removed from after a report was reviewed", and the member count drops.
Remove Contentreport.action: content_removed, the post removedThe post is removed and its author told.

By API, do it as Studio does. repo_update the report (community_block) with its whole report object ({ ...report, status, action, reviewedAt }) and reportStatus. Then remove the post (repo_update on the post, status: removed) for a ban or content removal. repo_update replaces report whole, so read it first.

POST /community/reports/<id>/review { action: "action_taken" | "dismissed", notes } only marks the report reviewed. It sets no report.action, so it warns nobody, bans nobody and leaves a held post flagged (tested). Use the Studio-style write above.

Stop before a warning, a ban or a removal. Each one tells the member, and a ban also ends their membership. Say who, what they did and which page or pages, and wait for yes.

Hashtags. Community › Hashtags bans a tag (community_hashtag status: banned). Posts with it are then refused: "#skilltest isn't allowed in this community" (tested, any case). It also drops out of trending.

Check: Community › Moderation shows the report as action taken or dismissed. GET /client/community/feed?page=<id> as another member shows the post gone or back.

6. Community › Badges: rewards that award themselves

repo_create on community_badge (or Community › Badges › New Badge):

{ "title": "First Post", "description": "Posted for the first time", "criteria": "Post once",
  "category": "engagement", "rarity": "common", "icon": "🏃",
  "criteriaType": "posts_count", "criteriaValue": 1, "autoAward": true, "status": "active" }

criteriaType that award: posts_count, comments_count, stories_posted, pages_joined, messages_sent, connections_count, meetings_attended, reactions_received (community-badge.service.ts 12–21). A member earns the badge the moment their next action of that kind reaches criteriaValue. They get the notice "You earned the First Post badge — Post once", and earnedBy goes up. Tested. Awards aren't back-dated: a member who already qualified gets it only at their next post, comment, and so on (tested). status: retired hides a badge.

Check: GET /client/community/badges/mine as the member. The site shows earned badges beside the feed.

7. Community › Groups and Meetings: chats and meetups

These are made as page staff, by the owner, an admin or a moderator, through the member routes:

  • Group chat: POST /client/community/groups { page, title, members: [{ email }], maxMembers?, onlyAdminsCanPost?, allowMediaSharing? }. Every member must be an active page member ("… is not a member of this community"), and the page's chat must be on. Tested. Each message notifies the other group members in-app.
  • Meetup: POST /client/community/meetings { page, title, participantIds: [customer sk or email], startTime, duration (minutes), timezone, location or meetingLink, locationType }. Participants must be page members. A page meetup is confirmed at once, and the end time is worked out from the duration. Tested: each invitee got " invited you to , Sat, Nov 7, 8:00 AM CST"</em>, and an RSVP told the organizer <em>"… is going to …"</em>. Only the organizer or page staff can cancel it, and cancelling notifies everyone.</li> </ul> <p>Studio's <strong>Groups</strong> and <strong>Meetings</strong> tabs save the record directly. That skips the membership checks and sends no invitation notices.</p> <p><strong>Stop</strong> before scheduling a meetup. Every invitee is notified.</p> <h2 id="notices-and-emails">Notices and emails</h2><p>Community notices are <strong>in-app</strong> (<code>community_notification</code>): the bell on the site's community page, <code>GET /client/community/notifications</code>, and EventOxygen. Nothing is emailed, texted or pushed, <strong>except the invitation</strong>. A member's <code>notificationPreference</code> on their membership (<code>all</code>, <code>mentions</code>, <code>none</code>) mutes a page's notices. No route sets it; change it on the member record. People are never told about their own actions.</p> <div class="tw"><table><thead><tr><th>Moment</th><th>Notice</th><th>To</th></tr></thead><tbody><tr><td>Someone asks to join</td><td><code>page_join_request</code></td><td>the page's owners and admins</td></tr><tr><td>Join approved</td><td><code>system</code>: <em>You're in!…</em></td><td>the person</td></tr><tr><td>Role changed</td><td><code>system</code>: <em>You're now a moderator of…</em></td><td>the person</td></tr><tr><td>Invited</td><td><strong><code>community-invitation</code> email</strong> + <code>page_invite</code></td><td>the person</td></tr><tr><td>Comment / reply</td><td><code>comment</code></td><td>the post's author / the parent comment's author</td></tr><tr><td>Reaction on a post</td><td><code>reaction</code></td><td>the post's author</td></tr><tr><td>Announcement published</td><td><code>announcement</code></td><td>the members it targets</td></tr><tr><td>Post approved / removed by a moderator</td><td><code>system</code></td><td>the author</td></tr><tr><td>Report decided: warning / ban</td><td><code>system</code></td><td>the reported member</td></tr><tr><td>Badge earned</td><td><code>badge_earned</code></td><td>the member</td></tr><tr><td>Group message</td><td><code>group_message</code></td><td>the other group members</td></tr><tr><td>Direct message</td><td><code>message</code></td><td>the recipient</td></tr><tr><td>Connection request / accepted</td><td><code>connection_request</code> / <code>connection_accepted</code></td><td>the other person</td></tr><tr><td>Meetup invite / RSVP / cancelled</td><td><code>meeting_invite</code> / <code>system</code></td><td>invitees / organizer / everyone on it</td></tr></tbody></table></div><h2 id="when-it-s-done">When it's done</h2><p>Tell the person:</p> <ul> <li>the address, and what an outsider sees there (everything, a card with Join, or nothing);</li> <li>each page's rules in plain words, e.g. "Private, approval to join, members post, reported posts held";</li> <li>who is owner, admin and moderator;</li> <li>who was invited, and that each got an email;</li> <li>any announcements, badges and hashtag bans;</li> <li>for reports: the decision and its effect.</li> </ul> <p>Open Studio's <strong>Community</strong> (<code>/community</code>) on the <strong>Pages</strong> tab, and the community on the site.</p> <h2 id="what-it-can-t-do-yet">What it can't do yet</h2><ul> <li><strong>A report also works as a block for the person who made it.</strong> Reports are saved as block records, so the reporter stops seeing the reported member's posts and can't message them. The report also shows in their list of blocked people. Tested: a member who reported someone lost that person's posts from their feed, and the blocks list showed the report (<code>community-block.service.ts</code> 166–190, <code>community-post.service.ts</code> 181–185, <code>community-messaging.service.ts</code> 311–319).</li> <li><strong>The staff review route doesn't act</strong> (step 5): <code>POST /community/reports/<id>/review</code> doesn't warn, ban or remove.</li> <li><strong>Pages made through <code>POST /client/community/pages</code> have no status</strong>, so they are left out of the directory until it is set (step 2).</li> <li><strong>@mentions do nothing.</strong> They are saved as words (<code>@sam</code>), not emails, and nobody is notified (<code>community-post.service.ts</code> 430–433).</li> <li><strong>People search finds nobody across the community.</strong> <code>GET /client/community/people</code> without <code>page</code> and <code>people/suggestions</code> always return an empty list. With <code>page</code>, it lists the page's members and ignores <code>q</code> (tested).</li> <li><strong>Follows don't do anything yet.</strong> Following a person, page or hashtag saves a record, but it doesn't change the feed, notify anyone or count followers (<code>followerCount</code> stays 0, tested).</li> <li><strong>Badge criteria <code>events_attended</code> and <code>custom</code></strong> are offered in Studio, but nothing awards them. There is no route for staff to award a badge by hand.</li> <li><strong>Counts drift.</strong> A post removed by a moderator, or held as pending, still counts in the page's <code>postCount</code>. A member record made directly doesn't update <code>memberCount</code> (step 3). Studio's member count on the Pages list counts every status.</li> <li><strong>Studio's sidebar shortcuts</strong> (Pages, Feed, Moderation…) open the Community app on whatever tab was last open. The app ignores the tab they name (<code>ui/sidebars/links.ts</code> 254–268, <code>community/app.tsx</code> 43). Pick the tab inside the app. Read from code, not tested.</li> <li><strong>The invitation's plain-text version reads "You've been invited you to join…"</strong> when there is no inviter name, which happens whenever a customer invites (<code>default-email-templates.ts</code> 525). The HTML version is the one most people see.</li> </ul> <h2 id="don-ts">Don'ts</h2><ul> <li>Don't create an event's community page by hand. The event makes it.</li> <li>Don't leave a page without <code>status: "active"</code>.</li> <li>Don't moderate with <code>update-partial</code>. It skips the approval, removal, ban and warning reactions. Use <code>repo_update</code> or Studio.</li> <li>Don't use <code>POST /community/reports/<id>/review</code> to ban or warn.</li> <li>Don't invite, announce, schedule a meetup, warn or ban without a yes. Each one reaches real people.</li> <li>Don't promise email or push notices. Only the invitation is emailed.</li> <li>Don't treat a public page as private. Its posts, members and announcements are readable by anyone.</li> </ul> </article> <nav class="pager"> <a class="pg pg-p" href="../ai-skills/delivery-zones-and-agents.html"><span>Previous</span><b>Delivery zones and agents</b></a> <a class="pg pg-n" href="../ai-skills/pos-and-floor-operations.html"><span>Next</span><b>Run the till and the floor</b></a> </nav> </main> <nav class="toc" id="toc"></nav> </div> <script>/* Docs search. Loaded inline into every page by build.mjs. The index (search-index.js) is fetched with a <script> tag on first use, so this also works from disk. search.html?q=... renders the full result list. */ (function () { var q = document.getElementById('q'); if (!q) return; var ROOT = q.getAttribute('data-root') || ''; var wrap = q.parentNode; var pop = document.createElement('div'); pop.className = 'sr-pop'; pop.hidden = true; wrap.appendChild(pop); var list = document.getElementById('sr'); var idx = null, waiting = null, active = -1, hits = []; function load(cb) { if (idx) return cb(); if (waiting) { waiting.push(cb); return; } waiting = [cb]; var s = document.createElement('script'); s.src = ROOT + '/search-index.js'; s.onload = function () { var d = window.DOCS_SEARCH; idx = { pages: d.pages.map(function (p) { return { u: p[0], t: p[1], s: p[2], d: p[3], lt: p[1].toLowerCase(), ls: p[2].toLowerCase() }; }), secs: d.secs.map(function (x) { return { p: x[0], a: x[1], h: x[2], x: x[3], lh: x[2].toLowerCase(), lx: x[3].toLowerCase() }; }) }; var w = waiting; waiting = null; w.forEach(function (f) { f(); }); }; s.onerror = function () { waiting = null; pop.innerHTML = '<div class="sr-empty">Search index not found. Rebuild the docs.</div>'; pop.hidden = false; }; document.head.appendChild(s); } /* Filler words ("how do I refund a card") are dropped unless nothing else is left. */ var STOP = ' a an the to of in on for and or is are how do does i my me we our you your with by at it be can what where when why which this that from as into '; function terms(s) { var all = (s.toLowerCase().match(/[a-z0-9]+/g) || []).filter(function (t, i, a) { return a.indexOf(t) === i; }); var real = all.filter(function (t) { return STOP.indexOf(' ' + t + ' ') === -1; }); return real.length ? real : all; } function count(hay, t) { var n = 0, i = hay.indexOf(t); while (i !== -1 && n < 8) { n++; i = hay.indexOf(t, i + t.length); } return n; } function wordStart(hay, t) { var i = hay.indexOf(t); while (i !== -1) { if (i === 0 || !/[a-z0-9]/.test(hay[i - 1])) return true; i = hay.indexOf(t, i + 1); } return false; } /* Every term must appear somewhere in the section or its page title. Title and heading hits outweigh body hits; whole-word hits outweigh fragments. */ function search(query) { var ts = terms(query); if (!ts.length) return []; var phrase = ts.join(' '); var byPage = {}; idx.secs.forEach(function (s) { var p = idx.pages[s.p], score = 0; for (var i = 0; i < ts.length; i++) { var t = ts[i], hit = 0; if (p.lt.indexOf(t) !== -1) hit += wordStart(p.lt, t) ? 10 : 4; if (s.lh.indexOf(t) !== -1) hit += wordStart(s.lh, t) ? 7 : 3; var c = count(s.lx, t); if (c) hit += (wordStart(s.lx, t) ? 1.5 : 0.6) + Math.min(c, 8) * 0.25; if (p.ls.indexOf(t) !== -1) hit += 0.5; if (!hit) return; score += hit; } if (ts.length > 1) { if (p.lt.indexOf(phrase) !== -1) score += 12; if (s.lh.indexOf(phrase) !== -1) score += 9; else if (s.lx.indexOf(phrase) !== -1) score += 4; } if (!s.a) score += 0.5; var g = byPage[s.p] || (byPage[s.p] = { p: p, score: 0, secs: [] }); g.secs.push({ s: s, score: score }); if (score > g.score) g.score = score; }); var groups = Object.keys(byPage).map(function (k) { return byPage[k]; }); groups.forEach(function (g) { g.secs.sort(function (a, b) { return b.score - a.score; }); g.score += Math.min(g.secs.length - 1, 5) * 0.4; }); groups.sort(function (a, b) { return b.score - a.score; }); return groups.map(function (g) { g.ts = ts; return g; }); } function esc(s) { return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); } function mark(s, ts) { if (!ts.length) return esc(s); return s.split(new RegExp('(' + ts.join('|') + ')', 'gi')).map(function (part, i) { return i % 2 ? '<mark>' + esc(part) + '</mark>' : esc(part); }).join(''); } function snippet(text, ts, len) { var lx = text.toLowerCase(), pos = -1; for (var i = 0; i < ts.length; i++) { var j = lx.indexOf(ts[i]); if (j !== -1 && (pos === -1 || j < pos)) pos = j; } if (pos === -1) return mark(text.slice(0, len), ts) + (text.length > len ? '…' : ''); var start = Math.max(0, pos - 50); var sp = text.lastIndexOf(' ', start); if (start > 0 && sp > start - 20) start = sp + 1; var s = text.slice(start, start + len); return (start > 0 ? '…' : '') + mark(s, ts) + (start + len < text.length ? '…' : ''); } function href(p, a) { return ROOT + '/' + p.u + '.html' + (a ? '#' + a : ''); } function moreHref(v) { return ROOT + '/search.html?q=' + encodeURIComponent(v); } function renderPop() { var v = q.value.trim(); if (!v) { pop.hidden = true; return; } hits = search(v); active = hits.length ? 0 : -1; if (!hits.length) { pop.innerHTML = '<div class="sr-empty">No results for “' + esc(v) + '”.</div>'; } else { pop.innerHTML = hits.slice(0, 8).map(function (g, i) { var s = g.secs[0].s; return '<a class="sr-i' + (i === 0 ? ' on' : '') + '" href="' + href(g.p, s.a) + '">' + '<span class="sr-t">' + mark(g.p.t, g.ts) + (s.h ? ' <span class="sr-h">› ' + mark(s.h, g.ts) + '</span>' : '') + '</span>' + (g.p.s ? '<span class="sr-s">' + esc(g.p.s) + '</span>' : '') + '<span class="sr-x">' + snippet(s.x || g.p.d, g.ts, 160) + '</span></a>'; }).join('') + '<a class="sr-more" href="' + moreHref(v) + '">See all ' + hits.length + ' result' + (hits.length === 1 ? '' : 's') + ' →</a>'; } if (!list) { // Phones: the popup is position:fixed to the screen edges, just under the box. pop.style.top = window.innerWidth <= 900 ? (wrap.getBoundingClientRect().bottom + 8) + 'px' : ''; pop.hidden = false; } } function renderList() { var v = q.value.trim(); if (!v) { list.innerHTML = '<p class="sr-count">Type in the search box to search every page.</p>'; return; } var gs = search(v); list.innerHTML = '<p class="sr-count">' + gs.length + ' page' + (gs.length === 1 ? '' : 's') + ' match “' + esc(v) + '”.</p>' + gs.slice(0, 60).map(function (g) { var top = g.secs[0].s, rest = g.secs.slice(1, 5).filter(function (x) { return x.s.a; }); return '<div class="sr-g"><a href="' + href(g.p, top.a) + '">' + mark(g.p.t, g.ts) + (top.h ? ' › ' + mark(top.h, g.ts) : '') + '</a>' + (g.p.s ? '<span class="sr-s">' + esc(g.p.s) + '</span>' : '') + '<p>' + snippet(top.x || g.p.d, g.ts, 260) + '</p>' + (rest.length ? '<ul class="sr-sub">' + rest.map(function (x) { return '<li><a href="' + href(g.p, x.s.a) + '">' + mark(x.s.h, g.ts) + '</a></li>'; }).join('') + '</ul>' : '') + '</div>'; }).join(''); } function update() { load(function () { if (list) { renderList(); var u = q.value.trim() ? '?q=' + encodeURIComponent(q.value.trim()) : location.pathname.split('/').pop(); try { history.replaceState(null, '', u); } catch (e) {} } else renderPop(); }); } function setActive(i) { var items = pop.querySelectorAll('.sr-i'); if (!items.length) return; active = (i + items.length) % items.length; items.forEach(function (el, k) { el.classList.toggle('on', k === active); }); items[active].scrollIntoView({ block: 'nearest' }); } q.addEventListener('focus', function () { load(function () { if (!list && q.value.trim()) renderPop(); }); }); q.addEventListener('input', update); q.addEventListener('keydown', function (e) { if (e.key === 'ArrowDown') { e.preventDefault(); setActive(active + 1); } else if (e.key === 'ArrowUp') { e.preventDefault(); setActive(active - 1); } else if (e.key === 'Escape') { pop.hidden = true; q.blur(); } else if (e.key === 'Enter') { e.preventDefault(); var v = q.value.trim(); if (!v || list) return; var items = pop.querySelectorAll('.sr-i'); location.href = active >= 0 && items[active] ? items[active].getAttribute('href') : moreHref(v); } }); document.addEventListener('click', function (e) { if (!wrap.contains(e.target)) pop.hidden = true; }); document.addEventListener('keydown', function (e) { var tag = (document.activeElement && document.activeElement.tagName) || ''; var typing = /INPUT|TEXTAREA|SELECT/.test(tag) || (document.activeElement && document.activeElement.isContentEditable); if ((e.key === 'k' && (e.metaKey || e.ctrlKey)) || (e.key === '/' && !typing)) { e.preventDefault(); q.focus(); q.select(); } }); if (list) { var m = /[?&]q=([^&]*)/.exec(location.search); q.value = m ? decodeURIComponent(m[1].replace(/\+/g, ' ')) : ''; update(); if (!q.value) q.focus(); } })(); var mb=document.getElementById('menu'); if(mb){mb.addEventListener('click',function(){document.getElementById('side').classList.toggle('closed');});} if(window.innerWidth<=900){var s=document.getElementById('side'); if(s)s.classList.add('closed');} var art=document.querySelector('.body'), toc=document.getElementById('toc'); if(art&&toc){ var hs=art.querySelectorAll('h2,h3'); if(hs.length>1){ var html='<h4>On this page</h4>'; hs.forEach(function(h,i){ if(!h.id){h.id='s'+i+'-'+h.textContent.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');} html+='<a class="'+h.tagName.toLowerCase()+'" href="#'+h.id+'">'+h.textContent+'</a>'; }); toc.innerHTML=html; var links=toc.querySelectorAll('a'); var obs=new IntersectionObserver(function(es){ es.forEach(function(e){ if(e.isIntersecting){ links.forEach(function(l){l.classList.toggle('on', l.getAttribute('href')==='#'+e.target.id);}); }}); },{rootMargin:'-110px 0px -70% 0px'}); hs.forEach(function(h){obs.observe(h);}); } } </script> </body> </html>