sdocs-dev 1.18.0 → 1.19.2

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/bin/sdocs-dev.js CHANGED
@@ -64,7 +64,10 @@ function buildRouter() {
64
64
  r.register('comments', { handler: () => { console.log(helpText.COMMENTS_HELP); process.exit(0); } });
65
65
 
66
66
  // Setup / refresh / auto-update.
67
- r.register('setup', { handler: async (opts) => { await setup.runSetup({ force: true, yes: !!opts.yesFlag, dryRun: !!opts.dryRunFlag }); process.exit(0); } });
67
+ r.register('setup', { handler: async (opts) => { await setup.runSetup({
68
+ force: true, yes: !!opts.yesFlag, dryRun: !!opts.dryRunFlag,
69
+ edition: opts.skillEdition, accountId: opts.accountFlag,
70
+ }); process.exit(0); } });
68
71
  r.register('refresh', { handler: async () => { await setup.runRefresh(); process.exit(0); } });
69
72
  r.register('auto-update', { handler: (opts) => {
70
73
  // Sub-arg lives in opts.file (positional). Accept on/off/empty.
@@ -116,10 +119,15 @@ function buildRouter() {
116
119
  r.register('library', { handler: async (opts) => { await libraryCommands.libraryCommand(opts); /* libraryOpen blocks */ } });
117
120
  r.register('cloud', { handler: async (opts) => { await cloudCommands.runCloudCommand(opts); } });
118
121
 
119
- r.register(null, { handler: (opts) => {
120
- // Index-on-open tap: fires before the open so any +tag CLI args land
121
- // in the file's front matter before the browser receives the content.
122
- libraryCommands.tapOpen(opts);
122
+ r.register(null, { handler: async (opts) => {
123
+ const cloudFirst = setup.cloudFirstSettings();
124
+ if (cloudFirst) {
125
+ await cloudCommands.autoSyncOpen(opts, { accountId: cloudFirst.accountId });
126
+ } else {
127
+ // Index-on-open tap: fires before the open so any +tag CLI args land
128
+ // in the file's front matter before the browser receives the content.
129
+ libraryCommands.tapOpen(opts);
130
+ }
123
131
  return commands.openCommand(opts);
124
132
  } });
125
133
 
@@ -163,6 +171,9 @@ module.exports = {
163
171
  compressAndEncrypt: shortLink.compressAndEncrypt,
164
172
  uploadShortLink: shortLink.uploadShortLink,
165
173
  buildShortUrl: shortLink.buildShortUrl,
174
+ normalizePlacementId: shortLink.normalizePlacementId,
175
+ buildAttributionQuery: shortLink.buildAttributionQuery,
176
+ assembleShortUrl: shortLink.assembleShortUrl,
166
177
 
167
178
  // Default styles
168
179
  mergeStyles: styles.mergeStyles,
@@ -28,16 +28,16 @@ const path = require('path');
28
28
  const { SETUP_CACHE } = require('./constants');
29
29
 
30
30
  // ── Skill model ────────────────────────────────────────────
31
- const SKILL_VERSION = 27;
32
- const SKILL_REASON = 'Agents can now guide readers through rendered prose, rich blocks, and inline code lines.';
31
+ const SKILL_VERSION = 29;
32
+ const SKILL_REASON = 'The Cloud edition now treats ordinary sdoc opens as automatic Cloud creates or updates and keeps tags in Cloud.';
33
33
  const SKILL_NAME = 'smalldocs';
34
34
 
35
35
  // Always-in-context preamble. Concise trigger text; the full reference lives
36
36
  // in SKILL_BODY and loads on demand. Plain text: no backticks, no em/en dashes,
37
37
  // no double quotes (it is emitted as a double-quoted YAML scalar).
38
- const SKILL_DESCRIPTION = "Use SmallDocs when the user says sdoc, S-doc, smalldoc, sdoc this, or asks to open, present, share, style, save, or walk through a Markdown document with SmallDocs. Create or locate the Markdown file and use sdoc FILE.md for normal viewing. For a document walkthrough add source-line annotations to the sdoc command; the source file stays unchanged. For a presentation run sdoc slides, create a Markdown deck from the source material, run sdoc slides verify FILE.md --json and fix every error, then run sdoc present FILE.md. For a runnable browser component run sdoc apps, use a sdoc-app block, and check inline, fullscreen, narrow, and wide layouts. Run the matching bare reference command before other specialised syntax. Local files stay local unless the user explicitly requests sharing or Cloud storage.";
38
+ const SKILL_DESCRIPTION = "Use SmallDocs when the user says sdoc, S-doc, smalldoc, sdoc this, or asks to open, present, share, style, save, or walk through a Markdown document with SmallDocs. Create or locate the Markdown file and use sdoc FILE.md for normal viewing. For a document walkthrough add source-line annotations to the sdoc command; the source file stays unchanged. For a presentation run sdoc slides, create a Markdown deck from the source material, run sdoc slides verify FILE.md --json and fix every error, then run sdoc present FILE.md. Use a runnable browser component only when the reader must interact with the result to understand something static document forms cannot express as clearly. When that criterion is met, run sdoc apps, use an sdoc-app block, and check inline, fullscreen, narrow, and wide layouts. Run the matching bare reference command before other specialised syntax. Local files stay local unless the user explicitly requests sharing or Cloud storage.";
39
39
 
40
- const CLOUD_SKILL_DESCRIPTION = "Use SmallDocs when the user says sdoc, S-doc, smalldoc, sdoc this, or asks to open, present, share, style, search, save, or walk through a Markdown document with SmallDocs. For a document walkthrough add source-line annotations to the sdoc command; the source file stays unchanged. For a presentation run sdoc slides, create a Markdown deck, run sdoc slides verify FILE.md --json and fix every error, then run sdoc present FILE.md. For a runnable browser component run sdoc apps, use a sdoc-app block, and check inline, fullscreen, narrow, and wide layouts. This user has enabled SmallDocs Cloud. Use sdoc FILE.md for ordinary local viewing and do not search Cloud when a named local source is sufficient. For relevant prior Cloud material run sdoc cloud status --json, search, then sdoc cloud pull DOCUMENT_UUID --output PATH --no-bind --json.";
40
+ const CLOUD_SKILL_DESCRIPTION = "Use SmallDocs when the user says sdoc, S-doc, smalldoc, sdoc this, or asks to open, present, share, style, search, save, or walk through a Markdown document with SmallDocs. This user has enabled Cloud-first mode: use sdoc FILE.md for new and updated Markdown so the CLI automatically creates or updates the Cloud copy before opening it. Use +tag arguments for Cloud tags. For relevant prior Cloud material run sdoc cloud status --json, search, then pull. For a presentation run sdoc slides, create a Markdown deck, run sdoc slides verify FILE.md --json and fix every error, run sdoc FILE.md to store it, then run sdoc present FILE.md. Use a runnable browser component only when the reader must interact with the result to understand something static document forms cannot express as clearly. When that criterion is met, run sdoc apps and check inline, fullscreen, narrow, and wide layouts.";
41
41
 
42
42
  const STANDARD_CLOUD_SKILL_SECTION = `### SmallDocs Cloud is available
43
43
 
@@ -72,6 +72,8 @@ ${STANDARD_CLOUD_SKILL_SECTION}
72
72
 
73
73
  SmallDocs uses the browser to extend what Markdown can be: a styled doc, a chart, a diagram, a slide deck, or an interactive form whose answers come back to you. Reach for one of these when a visual or interactive artifact will land harder than prose - not as a default for every reply. To create something new, write the \`.md\` file first, then \`sdoc path/to/file.md\`.
74
74
 
75
+ Use runnable HTML only when the interaction carries meaning that prose, a Mermaid diagram, a chart, or computed cells cannot express as clearly. Choose it when the reader must manipulate inputs, simulate behavior, rotate or zoom a spatial model, or explore linked states to understand the result. A three-input financial model may warrant an interactive surface when the relationship between assumptions matters; exact values alone normally belong in cells or a table. Before authoring, state what the interaction helps the reader understand. If the reason describes only appearance or layout, use a static document form.
76
+
75
77
  Each command below prints its reference when run with no arguments - run it before writing the matching fenced block. The JSON / DSL shapes are specific and easy to get wrong from memory.
76
78
 
77
79
  For a presentation request, follow this sequence rather than treating it as an ordinary document:
@@ -83,7 +85,7 @@ For a presentation request, follow this sequence rather than treating it as an o
83
85
 
84
86
  - \`sdoc charts\` - rendering inline charts (\`\`\`chart blocks)
85
87
  - \`sdoc diagrams\` - rendering inline Mermaid diagrams (\`\`\`mermaid blocks; has full-screen mode for zoom). Reach for this when drawing system or architectural diagrams (sequence, flow, component layout) - a diagram often communicates the shape of something faster than the equivalent prose.
86
- - \`sdoc apps\` - runnable HTML components (\`\`\`sdoc-app blocks): one complete HTML document with its own CSS, JavaScript, and data. Start with semantic HTML: SmallDocs supplies the document's current typography, colours, spacing, background, and control treatment in a low-priority CSS layer. Ordinary component CSS wins, and the \`--sdoc-app-*\` custom properties support targeted overrides. Let the tool's purpose determine its layout: prefer a clear page, list, table, or form before a dashboard of cards, use a canvas or stage for spatial interaction, and add surfaces, colour, and distinctive shapes when they encode structure or state. The component's document layout owns its inline height, while its width follows the reading column. Write responsive CSS for both the column and fullscreen viewport. It expands without losing state and joins Previous / Next navigation when the document contains several components. Use \`<title>\` to name it. Ordinary \`\`\`html remains source. Run \`sdoc apps\` before authoring and test every control inline, fullscreen, narrow, and wide.
88
+ - \`sdoc apps\` - runnable HTML components (\`\`\`sdoc-app blocks): one complete HTML document with its own CSS, JavaScript, and data. Use it when manipulation, simulation, spatial navigation, or linked states communicate something prose, diagrams, charts, or cells cannot express as clearly. If a static form communicates the result equally well, use it. Start with semantic HTML: SmallDocs supplies the document's current typography, colours, spacing, background, and control treatment in a low-priority CSS layer. Ordinary component CSS wins, and the \`--sdoc-app-*\` custom properties support targeted overrides. Let the tool's purpose determine its layout: prefer a clear page, list, table, or form before a dashboard of cards, use a canvas or stage for spatial interaction, and add surfaces, colour, and distinctive shapes when they encode structure or state. The component's document layout owns its inline height, while its width follows the reading column. Write responsive CSS for both the column and fullscreen viewport. It expands without losing state and joins Previous / Next navigation when the document contains several components. Use \`<title>\` to name it. Ordinary \`\`\`html remains source. Run \`sdoc apps\` before authoring and test every control inline, fullscreen, narrow, and wide.
87
89
  - \`sdoc slides\` - inline slide decks (\`\`\`slide / ~~~slide blocks; has full-screen presentation mode). Slides can be standalone exported as \`.pdf\` or \`.pptx\`. Run \`sdoc slides verify file.md --json\` after authoring; fix every diagnostic, or add \`bleed=allow\` only to an individual shape whose off-canvas placement is intentional, then rerun until it exits 0. Use \`sdoc present file.md\` for the visual check that headless validation cannot perform.
88
90
  - \`sdoc cells\` - rendering spreadsheets (\`\`\`cells blocks): CSV rows where plain values and =formulas (SUM, AVERAGE, IF, ROUND...) sit in the same grid and compute live. The reader can sort, select ranges for quick stats, edit a scratch copy fullscreen, and download the sheet as Excel (.xlsx) with the formulas still working. Name a block (\`\`\`cells Expenses) to build a workbook of several tabs whose formulas reference each other across sheets (\`=Expenses!B4\`); run \`sdoc cells verify file.md\` to compute the whole workbook headlessly and read the values back. Reach for this when handing the user numbers they will want to check or play with - totals, budgets, projections. \`sdoc report.csv\` opens a CSV file directly as a sheet.
89
91
  - \`sdoc code\` - opening a source file or a fenced code block as a syntax-highlighted listing: a light code viewer for reading code with the user away from the IDE. \`sdoc app.rb\` (or \`.js\`, \`.py\`, \`.go\`, \`.rs\`, \`.ts\`...) opens a file as a highlighted listing; a \`\`\`lang fenced block is highlighted inline. Comments in the source get a prominent lane so the code reads clearly top to bottom. The fullscreen view adds a line-number gutter and language-aware folding (collapse a whole method or class); a comment mode lets the user annotate a line or method with review notes, kept in the browser rather than the file. You can also pin your own explanations to lines as you open a file - \`sdoc app.py 22:"the bug is here" 25-28:"wrong comparison"\` - and the file opens as a guided walkthrough: each note is a markdown callout below its line with a Prev / Next stepper, walked in the order you pass the notes (not line order). Name several files to narrate across them - \`sdoc app.py 5:"entry point" util.py 12:"it calls into here" app.py 9:"back here"\` - and each becomes a tab the walkthrough hops between. When the user asks you to walk them through code, an MR, a diff, or the current changes, build one of these. The file rides in the URL like any document; nothing is uploaded. Reach for it when reading or reviewing code with the user, not for prose.
@@ -93,7 +95,9 @@ For a presentation request, follow this sequence rather than treating it as an o
93
95
 
94
96
  const CLOUD_SKILL_SECTION = `### SmallDocs Cloud for agents
95
97
 
96
- This user has enabled SmallDocs Cloud. Local viewing remains the default when the request only asks to create or open a document. Consider Cloud without waiting for the user to say the word "Cloud" when the existing conversation or task calls for persistent storage, cross-device access, search, revisions, permissions, or notifications. If the intended destination is unclear and it changes who can access the document, discuss it with the user.
98
+ This user has enabled Cloud-first mode. Treat Cloud as the normal destination for SmallDocs Markdown. Write or update the local Markdown working copy, then run \`sdoc FILE.md\`. The CLI creates a Cloud document for an unbound file or pushes a revision for a bound file before it opens the browser. If the Cloud write fails, stop and report the error instead of presenting the local copy as saved.
99
+
100
+ Use \`sdoc FILE.md +tag1 +tag2\` to add Cloud tags. Do not add SmallDocs tags to local front matter and do not use the local library. Use \`sdoc cloud ls\`, \`sdoc cloud search\`, and \`sdoc cloud tags\` for discovery. Use \`sdoc share\` only when the user explicitly asks for an encrypted snapshot link rather than the managed Cloud document.
97
101
 
98
102
  Treat Cloud as a source of context, not only a place to save new work. When earlier decisions, research, plans, or documentation could materially inform the task, search Cloud before recreating that context. Use specific project terms first and try shorter terms or existing tags when a search returns nothing. Do not search unrelated Cloud documents merely because Cloud is enabled.
99
103
 
@@ -114,11 +118,24 @@ When earlier Cloud material should inform new work, use this sequence:
114
118
  - When updating a bound document, the local binding supplies the revision the agent edited. Cloud keeps separate changes from other writers; overlapping replacements may both remain. If the server combines content and the file did not change during upload, push writes the combined Markdown back to the local file. Inspect \`merge_classification\`, \`combined\`, and \`local_updated_from_cloud\` in the JSON result.
115
119
  - Inspect or recover history with \`sdoc cloud history DOCUMENT_UUID\` and \`sdoc cloud restore DOCUMENT_UUID --revision REVISION_UUID\`.
116
120
 
117
- Cloud documents are identified by UUID, not filename. An account is the billing and access boundary; tags organize documents inside it. Do not use \`sdoc share\` as a substitute for Cloud: share creates an encrypted snapshot link, while Cloud provides revisions, search, membership, and persistent agent access.
121
+ Cloud documents are identified by UUID, not filename. An account is the access boundary; tags organize documents inside it. The local file remains the editable working copy and its binding records the Cloud document and base revision.
118
122
 
119
123
  `;
120
124
 
121
- const CLOUD_SKILL_BODY = SKILL_BODY.replace(STANDARD_CLOUD_SKILL_SECTION, CLOUD_SKILL_SECTION);
125
+ const CLOUD_SKILL_BODY = SKILL_BODY
126
+ .replace(
127
+ 'The `sdoc` CLI (`sdoc path/to/file.md`) is installed globally and renders local Markdown files securely in the browser (at https://smalldocs.org) in a way that\'s comfortable for your user to read and share. Nothing hits a server unless the user explicitly saves the file to the SmallDocs cloud or runs `sdoc share`.',
128
+ 'The `sdoc` CLI (`sdoc path/to/file.md`) is installed globally and renders Markdown in the browser at https://smalldocs.org. Cloud-first mode uploads a new Markdown file or pushes a bound file before opening it, so the Cloud copy stays current while the local file remains the editable working copy.',
129
+ )
130
+ .replace(
131
+ '- `sdoc file.md` - the default way to open a file, for comfortable reading or quick sharing.',
132
+ '- `sdoc file.md` - create or update the Cloud document, then open it for reading.',
133
+ )
134
+ .replace(
135
+ '- `sdoc library` - opens a library view containing files previously opened with `sdoc path/to/file.md`; filter by directory, date, or tags (the index doesn\'t search file content - fall back to `grep` for that). Opt out per-file with `sdocs-library: false` in front matter. (`sdoc library --help` for the full reference.)\n- `sdoc library ls --tags` - list the current project\'s tags by frequency. When tags would make a document worth rediscovering, run this before choosing them. Prefer an existing tag that fits; introduce a new one when none does.\n- `sdoc file.md +tag1 +tag2` - open the file and add the selected tags to its YAML front matter. The `+` prefix is shell-safe and the tags persist.',
136
+ '- `sdoc cloud ls` - list documents in Cloud.\n- `sdoc cloud tags` - list Cloud tags by frequency. Prefer an existing tag that fits when the document should be easy to find again.\n- `sdoc file.md +tag1 +tag2` - create or update the Cloud document and add the selected Cloud tags. The local Markdown is not changed.',
137
+ )
138
+ .replace(STANDARD_CLOUD_SKILL_SECTION, CLOUD_SKILL_SECTION);
122
139
 
123
140
  function formatSkill(version, options) {
124
141
  const cloud = Boolean(options && options.cloud);
@@ -110,7 +110,7 @@ function pathsResolveSame(a, b) {
110
110
 
111
111
  // ── canonical skill ────────────────────────────────────────
112
112
 
113
- function refreshCanonicalSkill(home) {
113
+ function refreshCanonicalSkill(home, opts = {}) {
114
114
  home = home || os.homedir();
115
115
  const file = canonicalSkillFile(home);
116
116
  let existing = null;
@@ -130,23 +130,27 @@ function refreshCanonicalSkill(home) {
130
130
  }
131
131
  const currentVersion = existing ? readSkillVersion(existing) : null;
132
132
  const currentEdition = existing ? readSkillEdition(existing) : 'standard';
133
+ const requestedEdition = opts.edition === 'cloud' || opts.edition === 'standard'
134
+ ? opts.edition : null;
135
+ const desiredEdition = requestedEdition || currentEdition;
133
136
  if (existing !== null && currentVersion === null) {
134
137
  return {
135
138
  changed: false, reason: 'conflict', path: file,
136
139
  error: 'existing canonical SKILL.md is not managed by SmallDocs; left untouched',
137
140
  };
138
141
  }
139
- if (currentVersion === SKILL_VERSION) {
142
+ if (currentVersion === SKILL_VERSION && currentEdition === desiredEdition) {
140
143
  return { changed: false, reason: 'current', path: file };
141
144
  }
142
145
  if (currentVersion !== null && currentVersion > SKILL_VERSION) {
143
146
  return { changed: false, reason: 'newer', path: file };
144
147
  }
145
148
  fs.mkdirSync(path.dirname(file), { recursive: true });
146
- atomicWrite(file, formatSkill(SKILL_VERSION, { cloud: currentEdition === 'cloud' }));
149
+ atomicWrite(file, formatSkill(SKILL_VERSION, { cloud: desiredEdition === 'cloud' }));
147
150
  return {
148
151
  changed: true, path: file,
149
152
  fromVersion: currentVersion || 0, toVersion: SKILL_VERSION,
153
+ fromEdition: currentEdition, toEdition: desiredEdition,
150
154
  };
151
155
  }
152
156
 
@@ -298,7 +302,7 @@ function syncAgentSkill(opts = {}) {
298
302
  const canonicalDir = canonicalSkillDir(home);
299
303
 
300
304
  const result = {
301
- canonical: refreshCanonicalSkill(home),
305
+ canonical: refreshCanonicalSkill(home, { edition: opts.edition }),
302
306
  links: [],
303
307
  stripped: [],
304
308
  errors: [],
@@ -6,6 +6,7 @@ const constants = require('./constants');
6
6
  const io = require('./io');
7
7
  const credentialStore = require('./cloud-credentials');
8
8
  const bindings = require('./cloud-bindings');
9
+ const SDocYaml = require('../shared/sdocs-yaml.js');
9
10
 
10
11
  const EXIT = { unexpected: 1, invalid_request: 2, login_required: 3,
11
12
  resource_unavailable: 4, account_required: 4, account_selection_required: 4,
@@ -21,8 +22,8 @@ const EXIT = { unexpected: 1, invalid_request: 2, login_required: 3,
21
22
  const CLOUD_HELP = `SmallDocs Cloud
22
23
 
23
24
  Cloud stores selected Markdown documents with search, revisions, member access,
24
- and cross-device CLI and browser access. Local files are not uploaded until a
25
- create or push command changes Cloud.
25
+ and cross-device CLI and browser access. Run \`sdoc setup --cloud --yes\` to make
26
+ ordinary \`sdoc FILE.md\` opens create or update the Cloud document first.
26
27
 
27
28
  DISCOVER AND READ
28
29
 
@@ -210,6 +211,7 @@ class CloudClient {
210
211
  function emit(opts, command, value, human) {
211
212
  if (opts.jsonFlag) process.stdout.write(JSON.stringify(Object.assign({ ok: true, command }, value)) + '\n');
212
213
  else process.stdout.write((human || JSON.stringify(value, null, 2)) + '\n');
214
+ return value;
213
215
  }
214
216
 
215
217
  function skillInstallCommand(originValue, cloud) {
@@ -234,13 +236,15 @@ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
234
236
 
235
237
  async function login(opts, client) {
236
238
  const installCommand = skillInstallCommand(client.origin, true);
239
+ const setupCommand = 'sdoc setup --cloud --yes';
237
240
  if (client.loadCredential()) {
238
241
  try {
239
242
  const me = await client.authenticated('/api/cloud/v1/me');
240
243
  return emit(opts, 'cloud.login', { user: me.user, already_logged_in: true,
241
- skill_mode: 'cloud', skill_install_command: installCommand },
244
+ skill_mode: 'cloud', skill_install_command: installCommand,
245
+ cloud_first_setup_command: setupCommand },
242
246
  'Already signed in as ' + (me.user.email || me.user.id) + '.\n' +
243
- 'Install or refresh the Cloud-aware SmallDocs skill:\n' + installCommand);
247
+ 'Enable Cloud-first SmallDocs on this machine:\n' + setupCommand);
244
248
  } catch (_) {}
245
249
  }
246
250
  const issued = await client.raw('/api/cloud/v1/cli/device-authorizations', {
@@ -266,10 +270,10 @@ async function login(opts, client) {
266
270
  client.saveCredential(credential);
267
271
  return emit(opts, 'cloud.login', { user_id: credential.user_id,
268
272
  credential_id: credential.credential_id, skill_mode: 'cloud',
269
- skill_install_command: installCommand },
273
+ skill_install_command: installCommand,
274
+ cloud_first_setup_command: setupCommand },
270
275
  'Cloud login saved for this machine.\n' +
271
- 'Install the Cloud-aware SmallDocs skill so agents can discover Cloud when relevant:\n' +
272
- installCommand);
276
+ 'Enable Cloud-first SmallDocs on this machine:\n' + setupCommand);
273
277
  }
274
278
  throw new CloudCommandError('login_required', 'Authorization expired before it was approved.');
275
279
  }
@@ -282,11 +286,13 @@ async function logout(opts, client) {
282
286
  client.credentials.remove(client.origin);
283
287
  }
284
288
  const restoreCommand = skillInstallCommand(client.origin, false);
289
+ const standardSetupCommand = 'sdoc setup --standard --yes';
285
290
  emit(opts, 'cloud.logout', { logged_out: true, skill_unchanged: true,
286
- standard_skill_install_command: restoreCommand },
291
+ standard_skill_install_command: restoreCommand,
292
+ standard_setup_command: standardSetupCommand },
287
293
  'Signed out of SmallDocs Cloud. The installed skill was not changed.\n' +
288
- 'If you do not expect to use Cloud on this machine, restore the standard skill:\n' +
289
- restoreCommand);
294
+ 'If you do not expect to use Cloud on this machine, restore local-first behavior:\n' +
295
+ standardSetupCommand);
290
296
  }
291
297
 
292
298
  function filterTags(documents, tags) {
@@ -460,7 +466,7 @@ async function create(opts, client) {
460
466
  bindings.cacheBase(credential.user_id, document.id, document.current_revision_id, content);
461
467
  bindings.clearPending(credential.user_id, file);
462
468
  const localChanged = bindings.hash(fs.readFileSync(file)) !== digest;
463
- emit(opts, 'cloud.create', { document_id: document.id, revision_id: document.current_revision_id,
469
+ return emit(opts, 'cloud.create', { document_id: document.id, revision_id: document.current_revision_id,
464
470
  revision_number: document.revision_number,
465
471
  account_id: response.account && response.account.id || opts.accountFlag || null, path: file,
466
472
  tags: document.tags, sha256: digest, binding_created: true,
@@ -533,21 +539,32 @@ async function push(opts, client) {
533
539
  throw new CloudCommandError('unsafe_local_state',
534
540
  'file is not bound; provide both --document and --base-revision');
535
541
  }
536
- const content = fs.readFileSync(file, 'utf8');
537
- const digest = bindings.hash(content);
542
+ const localContent = fs.readFileSync(file, 'utf8');
543
+ const digest = bindings.hash(localContent);
538
544
  if (binding.content_sha256 === digest) {
539
545
  return emit(opts, 'cloud.push', { document_id: binding.document_id,
540
546
  base_revision_id: binding.revision_id, revision_id: binding.revision_id,
541
547
  sha256: digest, no_change: true }, 'No changes to push.');
542
548
  }
549
+ const targetMarkdown = bindings.readBase(credential.user_id, binding.document_id,
550
+ binding.revision_id);
551
+ let content = localContent;
552
+ if (opts.cloudFirst && targetMarkdown != null) {
553
+ const cloudMeta = SDocYaml.parseFrontMatter(targetMarkdown).meta || {};
554
+ const cloudTags = Array.isArray(cloudMeta.tags) ? cloudMeta.tags : [];
555
+ if (cloudTags.length) {
556
+ const parsed = SDocYaml.parseFrontMatter(localContent);
557
+ const meta = Object.assign({}, parsed.meta, { tags: cloudTags });
558
+ content = SDocYaml.serializeFrontMatter(meta) + '\n' + parsed.body;
559
+ }
560
+ }
561
+ const uploadDigest = bindings.hash(content);
543
562
  let pending = bindings.getPending(credential.user_id, file);
544
- if (!pending || pending.document_id !== binding.document_id || pending.base_revision_id !== binding.revision_id || pending.sha256 !== digest) {
563
+ if (!pending || pending.document_id !== binding.document_id || pending.base_revision_id !== binding.revision_id || pending.sha256 !== uploadDigest) {
545
564
  pending = { document_id: binding.document_id, base_revision_id: binding.revision_id,
546
- sha256: digest, idempotency_key: crypto.randomUUID() };
565
+ sha256: uploadDigest, idempotency_key: crypto.randomUUID() };
547
566
  bindings.setPending(credential.user_id, file, pending);
548
567
  }
549
- const targetMarkdown = bindings.readBase(credential.user_id, binding.document_id,
550
- binding.revision_id);
551
568
  const response = await client.authenticated('/api/cloud/v1/documents/' + encodeURIComponent(binding.document_id) + '/revisions', {
552
569
  method: 'POST', headers: { 'Content-Type': 'application/json' },
553
570
  body: JSON.stringify({ target_revision_id: binding.revision_id,
@@ -557,17 +574,27 @@ async function push(opts, client) {
557
574
  const document = response.document;
558
575
  const localChanged = bindings.hash(fs.readFileSync(file)) !== digest;
559
576
  const savedContent = typeof document.markdown === 'string' ? document.markdown : content;
560
- const savedDigest = bindings.hash(savedContent);
561
- const localUpdated = !localChanged && savedContent !== content;
577
+ let savedLocalContent = savedContent;
578
+ if (opts.cloudFirst) {
579
+ const saved = SDocYaml.parseFrontMatter(savedContent);
580
+ const local = SDocYaml.parseFrontMatter(localContent);
581
+ const meta = Object.assign({}, saved.meta);
582
+ if (Object.hasOwn(local.meta || {}, 'tags')) meta.tags = local.meta.tags;
583
+ else delete meta.tags;
584
+ savedLocalContent = Object.keys(meta).length
585
+ ? SDocYaml.serializeFrontMatter(meta) + '\n' + saved.body : saved.body;
586
+ }
587
+ const savedDigest = bindings.hash(savedLocalContent);
588
+ const localUpdated = !localChanged && savedLocalContent !== localContent;
562
589
  if (!localChanged) {
563
- if (localUpdated) atomicFileWrite(file, savedContent);
590
+ if (localUpdated) atomicFileWrite(file, savedLocalContent);
564
591
  bindings.set(credential.user_id, file, { document_id: document.id,
565
592
  revision_id: document.current_revision_id, content_sha256: savedDigest,
566
593
  updated_at: document.updated_at });
567
594
  }
568
595
  bindings.cacheBase(credential.user_id, document.id, document.current_revision_id, savedContent);
569
596
  bindings.clearPending(credential.user_id, file);
570
- emit(opts, 'cloud.push', { document_id: document.id, base_revision_id: binding.revision_id,
597
+ return emit(opts, 'cloud.push', { document_id: document.id, base_revision_id: binding.revision_id,
571
598
  revision_id: document.current_revision_id, revision_number: document.revision_number,
572
599
  tags: document.tags, sha256: savedDigest, no_change: false,
573
600
  merge_classification: document.merge_classification || 'clean',
@@ -579,6 +606,64 @@ async function push(opts, client) {
579
606
  : localUpdated ? ' and updated the local file with Cloud changes.' : '.'));
580
607
  }
581
608
 
609
+ function isMarkdownFile(file) {
610
+ return /\.(?:md|markdown|mdown|mkd)$/i.test(String(file || ''));
611
+ }
612
+
613
+ async function addCloudTags(documentId, requestedTags, client, local) {
614
+ const additions = Array.from(new Set((requestedTags || [])
615
+ .map((tag) => String(tag).trim().toLowerCase()).filter(Boolean)));
616
+ if (!additions.length) return null;
617
+ const current = await client.authenticated('/api/cloud/v1/documents/'
618
+ + encodeURIComponent(documentId));
619
+ const existing = current.document && Array.isArray(current.document.tags)
620
+ ? current.document.tags : [];
621
+ const tags = Array.from(new Set(existing.concat(additions)));
622
+ if (tags.length === existing.length && tags.every((tag, index) => tag === existing[index])) {
623
+ return current.document;
624
+ }
625
+ const response = await client.authenticated('/api/cloud/v1/documents/'
626
+ + encodeURIComponent(documentId) + '/tags', {
627
+ method: 'PATCH', headers: { 'Content-Type': 'application/json' },
628
+ body: JSON.stringify({ tags,
629
+ expected_head_revision_id: current.document.current_revision_id,
630
+ idempotency_key: crypto.randomUUID() }),
631
+ });
632
+ if (local && typeof response.document.markdown === 'string') {
633
+ const content = fs.readFileSync(local.file, 'utf8');
634
+ bindings.set(local.credential.user_id, local.file, {
635
+ document_id: documentId,
636
+ revision_id: response.document.current_revision_id,
637
+ content_sha256: bindings.hash(content),
638
+ updated_at: response.document.updated_at,
639
+ });
640
+ bindings.cacheBase(local.credential.user_id, documentId,
641
+ response.document.current_revision_id, response.document.markdown);
642
+ }
643
+ process.stdout.write('Updated Cloud tags for ' + documentId + '.\n');
644
+ return response.document;
645
+ }
646
+
647
+ async function autoSyncOpen(opts, dependencies) {
648
+ if (!opts.file || !isMarkdownFile(opts.file)) return { synced: false };
649
+ const client = dependencies && dependencies.client || new CloudClient();
650
+ const accountId = opts.accountFlag || dependencies && dependencies.accountId || null;
651
+ const file = requireFile(opts.file);
652
+ const credential = requireCredential(client);
653
+ const binding = bindings.get(credential.user_id, file);
654
+ const syncOpts = Object.assign({}, opts, {
655
+ extra: file,
656
+ accountFlag: accountId,
657
+ jsonFlag: false,
658
+ cloudFirst: true,
659
+ });
660
+ const result = binding ? await push(syncOpts, client) : await create(syncOpts, client);
661
+ const tagged = await addCloudTags(result.document_id, opts.addTags, client,
662
+ { credential, file });
663
+ return { synced: true, documentId: result.document_id,
664
+ revisionId: tagged && tagged.current_revision_id || result.revision_id, created: !binding };
665
+ }
666
+
582
667
  async function history(opts, client) {
583
668
  const documentId = opts.extra;
584
669
  if (!documentId) throw new CloudCommandError('invalid_request',
@@ -737,5 +822,5 @@ async function runCloudCommand(opts, dependencies) {
737
822
  }
738
823
  }
739
824
 
740
- module.exports = { CloudClient, CloudCommandError, runCloudCommand, filterTags, origin, EXIT, CLOUD_HELP,
741
- CLOUD_OVERVIEW, entitlementFailure, skillInstallCommand };
825
+ module.exports = { CloudClient, CloudCommandError, runCloudCommand, autoSyncOpen, filterTags, origin,
826
+ EXIT, CLOUD_HELP, CLOUD_OVERVIEW, entitlementFailure, skillInstallCommand };
@@ -115,7 +115,7 @@ function keychainOperations(operations) {
115
115
  return {
116
116
  read() { return null; },
117
117
  write(account, value) { keychainWriteAccount(account, value, operations); },
118
- remove: keychainDeleteAccount,
118
+ remove() {},
119
119
  };
120
120
  }
121
121
  return {
package/lib/commands.js CHANGED
@@ -44,6 +44,7 @@ async function finishUrl(opts, content, local, defaults) {
44
44
  try {
45
45
  const url = await buildShortUrl(content, {
46
46
  url: opts.url, mode: opts.mode, theme: opts.theme, section: opts.section,
47
+ source: opts.sourceFlag, placement: opts.placementFlag,
47
48
  });
48
49
  return { url, contentPresent: !!content };
49
50
  } catch (e) {
@@ -186,7 +187,7 @@ async function prepareUrl(opts) {
186
187
  // The non-blocking, share-by-URL case is `sdoc share <file>`.
187
188
  async function openCommand(opts) {
188
189
  const { url } = await prepareUrl(opts);
189
- openBrowser(url);
190
+ if (!opts.noOpenFlag) openBrowser(url);
190
191
  console.log(`SDocs → ${url.length > 80 ? url.slice(0, 77) + '...' : url}`);
191
192
  await postCommandHooks();
192
193
  }
package/lib/help-text.js CHANGED
@@ -20,6 +20,10 @@ USAGE
20
20
  sdoc share <file> Copy shareable link to clipboard
21
21
  sdoc share <file> --section "X" Link with section anchor
22
22
  sdoc share <file> --short Encrypted /s/<id> short link (see SHORT LINKS)
23
+ sdoc share <file> --short --source email-launch
24
+ Short link attributed to a named source
25
+ sdoc share <file> --short --source x --placement launch-reply-01
26
+ Social link attributed to one placement
23
27
  sdoc schema Print the full styles schema
24
28
  sdoc charts Chart types, options, and styling guide
25
29
  sdoc diagrams Mermaid diagrams reference (\`\`\`mermaid blocks)
@@ -55,6 +59,9 @@ USAGE
55
59
  sdoc defaults Show ~/.sdocs/styles.yaml
56
60
  sdoc defaults --reset Remove default styles
57
61
  sdoc setup Install the SmallDocs skill for your coding agents
62
+ sdoc setup --cloud --yes Enable Cloud-first opens and install the Cloud skill
63
+ sdoc setup --cloud --account ID Use one Cloud account for automatic creates
64
+ sdoc setup --standard --yes Return to local-first behavior
58
65
  sdoc refresh Refresh the SmallDocs skill to the current version
59
66
  sdoc auto-update [on|off] Toggle auto-install of sdoc updates
60
67
  sdoc upgrade Upgrade sdoc to the latest version now
@@ -83,7 +90,12 @@ OPTIONS
83
90
  --mode <m> Alias for --read / --write / --style / --raw / --comment
84
91
  --short Use the encrypted /s/<id> short-URL form (share
85
92
  subcommand only). See SHORT LINKS below.
93
+ --source <name> Attribute short-link visits to a source or campaign.
94
+ --placement <id> Attribute a social short link to one publishing
95
+ placement. Use 4-32 letters, numbers, underscores,
96
+ or hyphens with --source x, yt, or youtube.
86
97
  --json Machine-readable output for safe and Cloud commands.
98
+ --no-open Do not launch the browser after a normal file open.
87
99
  --audit Also print GitHub links to server-side source
88
100
  files (safe subcommand only).
89
101
  --keep-open feedback subcommand: keep the bridge alive across
@@ -202,6 +214,21 @@ SHORT LINKS (sdoc share --short)
202
214
 
203
215
  \`--short\` produces a shorter, encrypted form: \`https://smalldocs.org/s/<id>#k=<key>\`.
204
216
 
217
+ Add any source name when publishing a link, for example
218
+ \`--source email-launch\`, \`--source product-hunt\`, or \`--source x\`.
219
+ This produces a URL such as
220
+ \`https://smalldocs.org/s/<id>?src=email-launch#k=<key>\`. The source appears
221
+ automatically at \`/analytics/sources\`; the visit still counts in the
222
+ ordinary visit and short-link totals.
223
+
224
+ Add \`--placement launch-reply-01\` to an X or YouTube source link
225
+ when the same document is published in more than one place. This produces
226
+ \`https://smalldocs.org/s/<id>?src=x&pid=launch-reply-01#k=<key>\`.
227
+ The placement id is public and identifies the post, reply, or comment where
228
+ the link was used. It does not identify a visitor. Only links with a valid
229
+ placement id appear in the targeted-placement table at
230
+ \`/analytics/sources?type=short\`.
231
+
205
232
  How it works:
206
233
  1. The CLI brotli-compresses the content, generates a 256-bit AES-GCM
207
234
  key + 96-bit nonce locally, and encrypts the compressed bytes.
@@ -218,6 +245,8 @@ SHORT LINKS (sdoc share --short)
218
245
  What the server can see:
219
246
  - That a ciphertext blob was uploaded under id <id>.
220
247
  - When it was fetched and from which IP (standard server logs).
248
+ - For an opted-in placement link, its source, public placement id, and
249
+ short-link id when a visit is counted.
221
250
  What the server cannot see:
222
251
  - The plaintext document. It does not have the key.
223
252
  - The key. The key never leaves the URL fragment.
package/lib/io.js CHANGED
@@ -39,6 +39,8 @@ function parseArgs(argv) {
39
39
  let url = null;
40
40
  let subcommand = null;
41
41
  let section = null;
42
+ let sourceFlag = null;
43
+ let placementFlag = null;
42
44
  let theme = null;
43
45
  let resetFlag = false;
44
46
  let shortFlag = false;
@@ -55,6 +57,7 @@ function parseArgs(argv) {
55
57
  let helpFlag = false;
56
58
  let yesFlag = false;
57
59
  let dryRunFlag = false;
60
+ let skillEdition = null;
58
61
  let sheetName = null;
59
62
  let projectFlag = null;
60
63
  let accountFlag = null;
@@ -115,6 +118,8 @@ function parseArgs(argv) {
115
118
 
116
119
  if (arg === '--url') { url = args[++i]; continue; }
117
120
  if (arg === '--section' || arg === '-s') { section = args[++i]; continue; }
121
+ if (arg === '--source') { sourceFlag = args[++i]; continue; }
122
+ if (arg === '--placement') { placementFlag = args[++i]; continue; }
118
123
  if (arg === '--reset') { resetFlag = true; continue; }
119
124
  if (arg === '--short') { shortFlag = true; continue; }
120
125
  if (arg === '--json') { jsonFlag = true; continue; }
@@ -132,6 +137,8 @@ function parseArgs(argv) {
132
137
  if (arg === '--tags') { tagsFlag = true; continue; }
133
138
  if (arg === '--yes' || arg === '-y') { yesFlag = true; continue; }
134
139
  if (arg === '--dry-run') { dryRunFlag = true; continue; }
140
+ if (arg === '--cloud') { skillEdition = 'cloud'; continue; }
141
+ if (arg === '--standard') { skillEdition = 'standard'; continue; }
135
142
  if (arg === '--sheet') { sheetName = args[++i]; continue; }
136
143
  if (arg === '--project') { projectFlag = args[++i]; continue; }
137
144
  if (arg === '--account') { accountFlag = args[++i]; continue; }
@@ -198,11 +205,11 @@ function parseArgs(argv) {
198
205
  }
199
206
 
200
207
  return {
201
- file, extra, mode, url, subcommand, section, theme,
208
+ file, extra, mode, url, subcommand, section, sourceFlag, placementFlag, theme,
202
209
  resetFlag, shortFlag, jsonFlag, auditFlag, waitFlag,
203
210
  messageText, connectTimeoutS, idleTimeoutS, reconnectGraceMs,
204
211
  keepOpenFlag, logFile,
205
- tagsFlag, helpFlag, yesFlag, dryRunFlag, sheetName,
212
+ tagsFlag, helpFlag, yesFlag, dryRunFlag, skillEdition, sheetName,
206
213
  projectFlag, accountFlag, outputPath, revisionFlag, documentFlag, baseRevisionFlag,
207
214
  limitFlag, noOpenFlag, noBindFlag, forceFlag, tagFilters,
208
215
  everyoneFlag, onlyYouFlag, sharedWithMeFlag, noteText, memberFlags, documentFlags,
package/lib/setup.js CHANGED
@@ -20,6 +20,7 @@ const {
20
20
  SKILL_BODY,
21
21
  SKILL_NAME,
22
22
  formatSkill,
23
+ readSkillEdition,
23
24
  canonicalSkillFile,
24
25
  canonicalSkillDir,
25
26
  legacyBlockTargets,
@@ -80,12 +81,13 @@ async function askAutoRefreshConsent() {
80
81
  // Preview what setup would do: print the skill, the symlinks it would create,
81
82
  // the agents covered by the canonical copy, and any legacy blocks it would
82
83
  // strip. Touches no file and writes no state.
83
- function dryRunPreview() {
84
+ function dryRunPreview(edition) {
84
85
  const home = os.homedir();
85
86
  const env = process.env;
87
+ edition = edition || installedEdition(home);
86
88
  const skillPath = canonicalSkillFile(home);
87
89
  console.log(`--- ${skillPath} ---`);
88
- console.log(formatSkill(SKILL_VERSION));
90
+ console.log(formatSkill(SKILL_VERSION, { cloud: edition === 'cloud' }));
89
91
 
90
92
  const detected = detectSkillAgents(home, env);
91
93
  const linked = detected.filter(a => !a.universal);
@@ -116,7 +118,31 @@ function dryRunPreview() {
116
118
  }
117
119
  }
118
120
 
119
- async function runSetup({ force = false, yes = false, dryRun = false } = {}) {
121
+ function installedEdition(home) {
122
+ try { return readSkillEdition(fs.readFileSync(canonicalSkillFile(home), 'utf8')); }
123
+ catch (_) { return 'standard'; }
124
+ }
125
+
126
+ function setupStateFields(edition, accountId, existing) {
127
+ const activeEdition = edition || installedEdition(os.homedir());
128
+ return {
129
+ skillEdition: activeEdition,
130
+ cloudAccountId: activeEdition === 'cloud'
131
+ ? (accountId || existing && existing.cloudAccountId || null) : null,
132
+ };
133
+ }
134
+
135
+ function cloudFirstSettings() {
136
+ const state = readSetupState();
137
+ if (state) {
138
+ if (state.declined || state.skillEdition !== 'cloud') return null;
139
+ return { accountId: state.cloudAccountId || null };
140
+ }
141
+ return installedEdition(os.homedir()) === 'cloud' ? { accountId: null } : null;
142
+ }
143
+
144
+ async function runSetup({ force = false, yes = false, dryRun = false,
145
+ edition = null, accountId = null } = {}) {
120
146
  if (!force) {
121
147
  if (!process.stdout.isTTY || !process.stdin.isTTY) return;
122
148
  if (process.env.CI || process.env.SDOCS_NO_SETUP) return;
@@ -125,9 +151,10 @@ async function runSetup({ force = false, yes = false, dryRun = false } = {}) {
125
151
 
126
152
  // ── --yes (non-interactive) path ───────────────────────────
127
153
  if (yes) {
128
- if (dryRun) { dryRunPreview(); return; }
154
+ if (dryRun) { dryRunPreview(edition); return; }
129
155
 
130
- const result = syncAgentSkill({});
156
+ const existing = readSetupState();
157
+ const result = syncAgentSkill({ edition });
131
158
  const changed = syncChanged(result);
132
159
  const detected = detectSkillAgents(os.homedir(), process.env);
133
160
 
@@ -153,6 +180,7 @@ async function runSetup({ force = false, yes = false, dryRun = false } = {}) {
153
180
  autoRefreshAgentFiles: true,
154
181
  autoInstallUpdates: false,
155
182
  lastRunVersion: VERSION,
183
+ ...setupStateFields(edition, accountId, existing),
156
184
  });
157
185
  return;
158
186
  }
@@ -181,7 +209,9 @@ async function runSetup({ force = false, yes = false, dryRun = false } = {}) {
181
209
 
182
210
  const RULE = '\u2550'.repeat(36);
183
211
  console.log(`\n${RULE} Skill body ${RULE}`);
184
- console.log(SKILL_BODY.trim());
212
+ const displayEdition = edition || installedEdition(home);
213
+ console.log(formatSkill(SKILL_VERSION, { cloud: displayEdition === 'cloud' })
214
+ .replace(/^---[\s\S]*?---\n\n<!--[^\n]+-->\n<!--[^\n]+-->\n/, '').trim());
185
215
  console.log(RULE);
186
216
 
187
217
  const a = await ask('\nInstall? [Y/n/skip] ');
@@ -192,12 +222,14 @@ async function runSetup({ force = false, yes = false, dryRun = false } = {}) {
192
222
  writtenTo: [], declined: true,
193
223
  autoRefreshAgentFiles: false, autoInstallUpdates: false,
194
224
  lastRunVersion: VERSION,
225
+ ...setupStateFields(edition, accountId, readSetupState()),
195
226
  });
196
227
  console.log('Skipped. Run `sdoc setup` any time to revisit.');
197
228
  return;
198
229
  }
199
230
 
200
- const result = syncAgentSkill({});
231
+ const existing = readSetupState();
232
+ const result = syncAgentSkill({ edition });
201
233
  const changed = syncChanged(result);
202
234
  if (changed || result.errors.length) printSyncSummary(result);
203
235
  if (result.errors.length) return;
@@ -212,6 +244,7 @@ async function runSetup({ force = false, yes = false, dryRun = false } = {}) {
212
244
  autoRefreshAgentFiles: autoRefresh,
213
245
  autoInstallUpdates: autoInstall,
214
246
  lastRunVersion: VERSION,
247
+ ...setupStateFields(edition, accountId, existing),
215
248
  });
216
249
  console.log('\nDone. Run `sdoc setup` any time to revisit.');
217
250
  }
@@ -237,7 +270,7 @@ async function maybeAutoRefresh() {
237
270
  const next = implicitConsentState(toImplicitResults(result), VERSION);
238
271
  if (!next) return;
239
272
  printSyncSummary(result);
240
- writeSetupState(next);
273
+ writeSetupState({ ...next, ...setupStateFields(null, null, null) });
241
274
  return;
242
275
  }
243
276
 
@@ -271,6 +304,7 @@ async function runRefresh() {
271
304
  autoRefreshAgentFiles: existing ? existing.autoRefreshAgentFiles !== false : true,
272
305
  autoInstallUpdates: existing && existing.autoInstallUpdates != null ? existing.autoInstallUpdates : false,
273
306
  lastRunVersion: VERSION,
307
+ ...setupStateFields(null, null, existing),
274
308
  });
275
309
  }
276
310
 
@@ -304,4 +338,5 @@ module.exports = {
304
338
  runRefresh,
305
339
  runAutoUpdateSubcommand,
306
340
  maybeAutoRefresh,
341
+ cloudFirstSettings,
307
342
  };
package/lib/short-link.js CHANGED
@@ -18,6 +18,35 @@ const { slugify } = require('../shared/sdocs-slugify.js');
18
18
  const { toBase64Url } = require('./url');
19
19
  const { DEFAULT_URL } = require('./constants');
20
20
 
21
+ const PLACEMENT_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{3,31}$/;
22
+ const PLACEMENT_SOURCES = new Set(['x', 'yt', 'youtube']);
23
+
24
+ function normalizePlacementId(value) {
25
+ if (value == null || value === '') return '';
26
+ const placement = String(value).trim();
27
+ if (!PLACEMENT_RE.test(placement)) {
28
+ throw new Error('placement must be 4-32 letters, numbers, underscores, or hyphens');
29
+ }
30
+ return placement;
31
+ }
32
+
33
+ function buildAttributionQuery(sourceValue, placementValue) {
34
+ const source = String(sourceValue || '').trim().toLowerCase();
35
+ const placement = normalizePlacementId(placementValue);
36
+ if (placement && !PLACEMENT_SOURCES.has(source)) {
37
+ throw new Error('placement requires --source x, yt, or youtube');
38
+ }
39
+ const query = new URLSearchParams();
40
+ if (source) query.set('src', source);
41
+ if (placement) query.set('pid', placement);
42
+ return query.toString();
43
+ }
44
+
45
+ function assembleShortUrl(baseUrl, id, attributionQuery, fragmentParams) {
46
+ const sourceQuery = attributionQuery ? `?${attributionQuery}` : '';
47
+ return `${baseUrl}/s/${id}${sourceQuery}#${fragmentParams.toString()}`;
48
+ }
49
+
21
50
  // The blob format (nonce(12) + ciphertext + tag(16)) matches the browser.
22
51
  function compressAndEncrypt(content) {
23
52
  const compressed = zlib.brotliCompressSync(Buffer.from(content, 'utf-8'), {
@@ -72,6 +101,8 @@ function uploadShortLink(ciphertextB64, baseUrl) {
72
101
 
73
102
  async function buildShortUrl(content, opts) {
74
103
  if (!content) throw new Error('short link requires file content');
104
+ opts = opts || {};
105
+ const attributionQuery = buildAttributionQuery(opts.source, opts.placement);
75
106
 
76
107
  // Mirror the hash-build's default-stripping so the encrypted payload is
77
108
  // identical to what the browser would encode.
@@ -95,11 +126,17 @@ async function buildShortUrl(content, opts) {
95
126
  if (opts.theme) params.set('theme', opts.theme);
96
127
  if (opts.section) params.set('sec', slugify(opts.section));
97
128
 
98
- return `${baseUrl}/s/${id}#${params.toString()}`;
129
+ // Source attribution belongs in the query so the server can receive it. The
130
+ // encryption key stays after # and therefore never leaves the browser. The
131
+ // short-link id and encrypted payload are unchanged by this label.
132
+ return assembleShortUrl(baseUrl, id, attributionQuery, params);
99
133
  }
100
134
 
101
135
  module.exports = {
102
136
  compressAndEncrypt,
103
137
  uploadShortLink,
104
138
  buildShortUrl,
139
+ normalizePlacementId,
140
+ buildAttributionQuery,
141
+ assembleShortUrl,
105
142
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sdocs-dev",
3
- "version": "1.18.0",
3
+ "version": "1.19.2",
4
4
  "description": "Open, share, and style markdown files from the terminal",
5
5
  "main": "bin/sdocs-dev.js",
6
6
  "bin": {