@wxxb789/anc 0.0.2 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -34
- package/bin/anc.mjs +120 -11
- package/package.json +8 -1
- package/scripts/copy-assets.js +141 -0
- package/scripts/init-repository.js +18 -0
- package/scripts/load-config.js +258 -5
- package/scripts/markdown-to-artifact.js +310 -6
- package/scripts/publish-media.js +468 -0
- package/scripts/resolve-links.js +318 -42
- package/scripts/scan-residue.js +17 -6
- package/scripts/verify-output-inventory.js +53 -0
- package/src/lib/content.js +22 -0
- package/src/lib/font-coverage.js +845 -0
- package/src/lib/markdown.js +257 -11
- package/src/lib/media-codec.js +355 -0
- package/src/lib/media-delivery.js +76 -0
- package/src/lib/media.js +1426 -0
- package/src/lib/schema.js +83 -8
- package/src/lib/svg-css.js +685 -0
- package/src/lib/svg-policy.js +1699 -0
- package/src/lib/svg-refusal.js +1 -0
- package/src/lib/svg-render.js +343 -0
- package/src/pages/notes/[slug].astro +10 -1
- package/src/pages/privacy.astro +9 -5
- package/src/styles/global.css +29 -0
package/scripts/load-config.js
CHANGED
|
@@ -166,7 +166,28 @@ export const NEAR_MISS_NAMES = [
|
|
|
166
166
|
...['yaml', 'yml'].flatMap((extension) => [`publish-config.${extension}`, `publish.conf.${extension}`]),
|
|
167
167
|
];
|
|
168
168
|
/** Every key the file may carry. The order is the order they are documented in. */
|
|
169
|
-
const KNOWN_KEYS = ['title', 'origin', 'exclude', 'language'];
|
|
169
|
+
const KNOWN_KEYS = ['title', 'origin', 'exclude', 'language', 'media'];
|
|
170
|
+
/**
|
|
171
|
+
* Every key a `media:` table may carry.
|
|
172
|
+
*
|
|
173
|
+
* One provider and one shape, deliberately: Goal 0013 authorizes Cloudflare R2
|
|
174
|
+
* and nothing else, so this is a validated configuration table rather than a
|
|
175
|
+
* provider framework. The two credential entries are *names* of environment
|
|
176
|
+
* variables; a value written here would be a secret in the user's repository
|
|
177
|
+
* and is refused by the unknown-value rule of the shape itself.
|
|
178
|
+
*/
|
|
179
|
+
const MEDIA_KEYS = [
|
|
180
|
+
'provider',
|
|
181
|
+
'bucket',
|
|
182
|
+
'endpoint',
|
|
183
|
+
'public_base_url',
|
|
184
|
+
'access_key_id_env',
|
|
185
|
+
'secret_access_key_env',
|
|
186
|
+
];
|
|
187
|
+
/** The longest bucket name the S3 API and R2 both accept. */
|
|
188
|
+
const BUCKET_NAME = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/;
|
|
189
|
+
/** The shape of a name that can address an environment variable. */
|
|
190
|
+
const ENVIRONMENT_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
170
191
|
/**
|
|
171
192
|
* The site name a repository with no configuration gets.
|
|
172
193
|
*
|
|
@@ -193,6 +214,7 @@ export const DEFAULTS = Object.freeze({
|
|
|
193
214
|
origin: undefined,
|
|
194
215
|
exclude: Object.freeze([]),
|
|
195
216
|
language: undefined,
|
|
217
|
+
media: undefined,
|
|
196
218
|
});
|
|
197
219
|
/** The defaults, as a value a caller may keep and a later caller cannot see. */
|
|
198
220
|
function freshDefaults() {
|
|
@@ -307,17 +329,17 @@ function editDistance(from, to) {
|
|
|
307
329
|
* is true of any substring: measured, a key of `e` was suggested as `title`,
|
|
308
330
|
* `x` as `exclude`, and `o` as `origin`. A confidently wrong suggestion tells
|
|
309
331
|
* the user to rename a key they did not mean to write, which is worse than
|
|
310
|
-
* listing the
|
|
332
|
+
* listing the keys.
|
|
311
333
|
*/
|
|
312
|
-
function suggestionFor(key) {
|
|
334
|
+
function suggestionFor(key, candidates = KNOWN_KEYS) {
|
|
313
335
|
const folded = key.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
314
336
|
if (folded === '')
|
|
315
337
|
return undefined;
|
|
316
|
-
for (const known of
|
|
338
|
+
for (const known of candidates) {
|
|
317
339
|
if (folded.length >= 3 && (folded.includes(known) || known.includes(folded)))
|
|
318
340
|
return known;
|
|
319
341
|
}
|
|
320
|
-
for (const known of
|
|
342
|
+
for (const known of candidates) {
|
|
321
343
|
if (editDistance(folded, known) <= 2)
|
|
322
344
|
return known;
|
|
323
345
|
}
|
|
@@ -500,6 +522,231 @@ function checkLanguage(value, line, issues, details) {
|
|
|
500
522
|
details.push(`language = ${JSON.stringify(value)}`);
|
|
501
523
|
}
|
|
502
524
|
}
|
|
525
|
+
/**
|
|
526
|
+
* A bare HTTPS origin for the R2 storage endpoint or its public custom domain.
|
|
527
|
+
*
|
|
528
|
+
* The two checks that are not obvious, and the reason they are here rather
|
|
529
|
+
* than in the publisher:
|
|
530
|
+
*
|
|
531
|
+
* - **HTTPS only.** Credentials travel on every signed request, so a plain
|
|
532
|
+
* `http:` endpoint would put them on the wire. The public base is also where
|
|
533
|
+
* a reader's browser fetches images, and mixed content would break the page
|
|
534
|
+
* under the site's own CSP.
|
|
535
|
+
* - **No `r2.dev`.** That hostname is Cloudflare's development-only, rate
|
|
536
|
+
* limited endpoint with no normal CDN caching. A published site that used it
|
|
537
|
+
* would work in a test and degrade under real traffic, so it is refused at
|
|
538
|
+
* configuration time rather than discovered later.
|
|
539
|
+
*
|
|
540
|
+
* A path, query, fragment, username or password is refused for the same class
|
|
541
|
+
* of reason `checkOrigin` gives: the publisher appends the object path, so a
|
|
542
|
+
* path here would silently move every object and a credential would appear in
|
|
543
|
+
* every signed request line.
|
|
544
|
+
*/
|
|
545
|
+
function checkMediaOrigin(value, at, issues, details) {
|
|
546
|
+
if (typeof value !== 'string') {
|
|
547
|
+
issues.push(`${at}: must be a string holding an HTTPS origin, found ${typeName(value)}`);
|
|
548
|
+
details.push(`${at} = ${JSON.stringify(value)}`);
|
|
549
|
+
return undefined;
|
|
550
|
+
}
|
|
551
|
+
if (value !== value.trim() || /\s/.test(value)) {
|
|
552
|
+
issues.push(`${at}: must not contain whitespace`);
|
|
553
|
+
details.push(`${at} = ${JSON.stringify(value)}`);
|
|
554
|
+
return undefined;
|
|
555
|
+
}
|
|
556
|
+
let url;
|
|
557
|
+
try {
|
|
558
|
+
url = new URL(value);
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
issues.push(`${at}: must be an absolute HTTPS URL with a host, such as https://example.com/`);
|
|
562
|
+
details.push(`${at} = ${JSON.stringify(value)}`);
|
|
563
|
+
return undefined;
|
|
564
|
+
}
|
|
565
|
+
if (url.protocol !== 'https:') {
|
|
566
|
+
issues.push(`${at}: must use the https scheme`);
|
|
567
|
+
details.push(`${at} = ${JSON.stringify(value)}`);
|
|
568
|
+
return undefined;
|
|
569
|
+
}
|
|
570
|
+
if (url.username !== '' || url.password !== '') {
|
|
571
|
+
issues.push(`${at}: must not carry a username or password — credentials are read from the environment`);
|
|
572
|
+
// The value itself is the one string here that may be a secret.
|
|
573
|
+
details.push(`${at} carried credentials`);
|
|
574
|
+
return undefined;
|
|
575
|
+
}
|
|
576
|
+
const hostname = url.hostname.toLowerCase();
|
|
577
|
+
if (hostname === 'r2.dev' || hostname.endsWith('.r2.dev')) {
|
|
578
|
+
issues.push(`${at}: must not use r2.dev — it is Cloudflare's development-only, rate-limited ` +
|
|
579
|
+
'hostname with no normal CDN caching; connect a custom domain to the bucket instead');
|
|
580
|
+
details.push(`${at} named an r2.dev host`);
|
|
581
|
+
return undefined;
|
|
582
|
+
}
|
|
583
|
+
if (url.pathname !== '/' || url.search !== '' || url.hash !== '') {
|
|
584
|
+
issues.push(`${at}: must be a bare origin with no path, query, or fragment — ` +
|
|
585
|
+
'the object path is appended to it');
|
|
586
|
+
details.push(`${at} = ${JSON.stringify(value)}`);
|
|
587
|
+
return undefined;
|
|
588
|
+
}
|
|
589
|
+
return url.origin;
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* The `media:` table: one provider, one shape, and every required key present.
|
|
593
|
+
*
|
|
594
|
+
* Returns the normalized configuration only when this table contributed no
|
|
595
|
+
* issue. The caller still refuses the file if any other key did, which is why
|
|
596
|
+
* this does not throw: the loader accumulates every mistake in one pass.
|
|
597
|
+
*
|
|
598
|
+
* `bucket` is validated against the narrower of the S3 and R2 naming rules
|
|
599
|
+
* because both are addressed by the same value: 3–63 lowercase alphanumerics
|
|
600
|
+
* or hyphens, starting and ending with an alphanumeric. Rejecting a bucket the
|
|
601
|
+
* service would reject is cheaper than a 404 from a publisher that cannot say
|
|
602
|
+
* which of the two was wrong.
|
|
603
|
+
*/
|
|
604
|
+
function checkMedia(value, line, memberLines, tagged, issues, details) {
|
|
605
|
+
const at = line === undefined ? 'media' : `media (line ${line})`;
|
|
606
|
+
if (!isPlainObject(value)) {
|
|
607
|
+
issues.push(`${at}: must be a table, found ${typeName(value)}`);
|
|
608
|
+
details.push(`media = ${JSON.stringify(value)}`);
|
|
609
|
+
return undefined;
|
|
610
|
+
}
|
|
611
|
+
const before = issues.length;
|
|
612
|
+
const where = (key) => {
|
|
613
|
+
const memberLine = memberLines.get(key);
|
|
614
|
+
return memberLine === undefined ? `media.${key}` : `media.${key} (line ${memberLine})`;
|
|
615
|
+
};
|
|
616
|
+
// A nested key is user-authored text exactly as a top-level one is: a pasted
|
|
617
|
+
// path or credential can be a valid YAML key, and this message reaches the
|
|
618
|
+
// public stderr stream. The line is where the fix goes; the bytes go to the
|
|
619
|
+
// private report. `where()` remains for the schema's own key names below.
|
|
620
|
+
for (const key of tagged) {
|
|
621
|
+
const memberLine = memberLines.get(key);
|
|
622
|
+
issues.push(`${memberLine === undefined ? 'media' : `media (line ${memberLine})`}: the value carries a ` +
|
|
623
|
+
'"!" tag, which YAML discards — remove it, or quote the value if the "!" is part of it');
|
|
624
|
+
details.push(`media.${key} carried an eaten "!" tag`);
|
|
625
|
+
}
|
|
626
|
+
for (const key of Object.keys(value)) {
|
|
627
|
+
if (MEDIA_KEYS.includes(key))
|
|
628
|
+
continue;
|
|
629
|
+
const memberLine = memberLines.get(key);
|
|
630
|
+
const suggestion = suggestionFor(key, MEDIA_KEYS);
|
|
631
|
+
issues.push(`${memberLine === undefined ? 'media' : `media (line ${memberLine})`}: unknown key is not allowed` +
|
|
632
|
+
(suggestion === undefined
|
|
633
|
+
? `. The keys are ${MEDIA_KEYS.join(', ')}`
|
|
634
|
+
: `. Did you mean "${suggestion}"?`));
|
|
635
|
+
details.push(`unknown media key ${JSON.stringify(key)}`);
|
|
636
|
+
}
|
|
637
|
+
for (const key of MEDIA_KEYS) {
|
|
638
|
+
if (key in value)
|
|
639
|
+
continue;
|
|
640
|
+
issues.push(`${where(key)}: is required`);
|
|
641
|
+
}
|
|
642
|
+
if ('provider' in value && value['provider'] !== 'r2') {
|
|
643
|
+
issues.push(`${where('provider')}: must be "r2"; it is the only supported provider`);
|
|
644
|
+
details.push(`media.provider = ${JSON.stringify(value['provider'])}`);
|
|
645
|
+
}
|
|
646
|
+
if ('bucket' in value) {
|
|
647
|
+
const bucket = value['bucket'];
|
|
648
|
+
if (typeof bucket !== 'string') {
|
|
649
|
+
issues.push(`${where('bucket')}: must be a string, found ${typeName(bucket)}`);
|
|
650
|
+
details.push(`media.bucket = ${JSON.stringify(bucket)}`);
|
|
651
|
+
}
|
|
652
|
+
else if (bucket !== bucket.trim() || /\s/.test(bucket)) {
|
|
653
|
+
issues.push(`${where('bucket')}: must not contain whitespace`);
|
|
654
|
+
details.push(`media.bucket = ${JSON.stringify(bucket)}`);
|
|
655
|
+
}
|
|
656
|
+
else if (!BUCKET_NAME.test(bucket)) {
|
|
657
|
+
issues.push(`${where('bucket')}: must be 3–63 lowercase letters, digits, or hyphens, ` +
|
|
658
|
+
'starting and ending with a letter or digit');
|
|
659
|
+
details.push(`media.bucket = ${JSON.stringify(bucket)}`);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
const endpoint = 'endpoint' in value ? checkMediaOrigin(value['endpoint'], where('endpoint'), issues, details) : undefined;
|
|
663
|
+
const publicBaseUrl = 'public_base_url' in value
|
|
664
|
+
? checkMediaOrigin(value['public_base_url'], where('public_base_url'), issues, details)
|
|
665
|
+
: undefined;
|
|
666
|
+
const envNames = new Map();
|
|
667
|
+
for (const key of ['access_key_id_env', 'secret_access_key_env']) {
|
|
668
|
+
if (!(key in value))
|
|
669
|
+
continue;
|
|
670
|
+
const name = value[key];
|
|
671
|
+
if (typeof name !== 'string') {
|
|
672
|
+
issues.push(`${where(key)}: must be a string naming an environment variable, found ${typeName(name)}`);
|
|
673
|
+
details.push(`media.${key} = ${JSON.stringify(name)}`);
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
if (!ENVIRONMENT_NAME.test(name) || name.length > 128) {
|
|
677
|
+
issues.push(`${where(key)}: must be an environment variable name — letters, digits, and ` +
|
|
678
|
+
'underscores, not starting with a digit');
|
|
679
|
+
details.push(`media.${key} = ${JSON.stringify(name)}`);
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
envNames.set(key, name);
|
|
683
|
+
}
|
|
684
|
+
const accessKeyIdEnv = envNames.get('access_key_id_env');
|
|
685
|
+
const secretAccessKeyEnv = envNames.get('secret_access_key_env');
|
|
686
|
+
if (accessKeyIdEnv !== undefined && accessKeyIdEnv === secretAccessKeyEnv) {
|
|
687
|
+
issues.push(`${where('secret_access_key_env')}: must name a different environment variable from ` +
|
|
688
|
+
'media.access_key_id_env — one value cannot be both the key ID and the secret');
|
|
689
|
+
details.push('media.access_key_id_env and media.secret_access_key_env named the same variable');
|
|
690
|
+
}
|
|
691
|
+
if (issues.length !== before)
|
|
692
|
+
return undefined;
|
|
693
|
+
return {
|
|
694
|
+
provider: 'r2',
|
|
695
|
+
bucket: value['bucket'],
|
|
696
|
+
endpoint: endpoint,
|
|
697
|
+
publicBaseUrl: publicBaseUrl,
|
|
698
|
+
accessKeyIdEnv: accessKeyIdEnv,
|
|
699
|
+
secretAccessKeyEnv: secretAccessKeyEnv,
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* The nested `media:` members whose YAML value carried an eaten `!` tag.
|
|
704
|
+
*
|
|
705
|
+
* The top-level loop cannot see these: a tagged scalar inside a mapping is a
|
|
706
|
+
* different node, and `bucket: ! mybucket` would otherwise be accepted with the
|
|
707
|
+
* tag silently discarded. Same rule, one level down.
|
|
708
|
+
*/
|
|
709
|
+
function mediaEatenTags(document) {
|
|
710
|
+
const eaten = new Set();
|
|
711
|
+
const node = document.get('media', true);
|
|
712
|
+
const items = node !== null && typeof node === 'object' && 'items' in node ? node.items : undefined;
|
|
713
|
+
if (!Array.isArray(items))
|
|
714
|
+
return eaten;
|
|
715
|
+
for (const item of items) {
|
|
716
|
+
const pair = item;
|
|
717
|
+
const key = pair.key;
|
|
718
|
+
if (key === undefined || typeof key.value !== 'string')
|
|
719
|
+
continue;
|
|
720
|
+
if (carriesEatenTag(pair.value, document))
|
|
721
|
+
eaten.add(key.value);
|
|
722
|
+
}
|
|
723
|
+
return eaten;
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* The 1-indexed line each `media:` member was written on.
|
|
727
|
+
*
|
|
728
|
+
* Per member, not per table: the entire disclosure design rests on the user
|
|
729
|
+
* opening their own file at that line and reading their own text there, so a
|
|
730
|
+
* line that points at `media:` for an error in `bucket` is worse than none.
|
|
731
|
+
*/
|
|
732
|
+
function mediaLines(document, lineCounter) {
|
|
733
|
+
const lines = new Map();
|
|
734
|
+
const node = document.get('media', true);
|
|
735
|
+
const items = node !== null && typeof node === 'object' && 'items' in node ? node.items : undefined;
|
|
736
|
+
if (!Array.isArray(items))
|
|
737
|
+
return lines;
|
|
738
|
+
for (const item of items) {
|
|
739
|
+
const pair = item;
|
|
740
|
+
const key = pair.key;
|
|
741
|
+
if (key === undefined || typeof key.value !== 'string')
|
|
742
|
+
continue;
|
|
743
|
+
const range = key.range;
|
|
744
|
+
if (!Array.isArray(range) || typeof range[0] !== 'number')
|
|
745
|
+
continue;
|
|
746
|
+
lines.set(key.value, lineCounter.linePos(range[0]).line);
|
|
747
|
+
}
|
|
748
|
+
return lines;
|
|
749
|
+
}
|
|
503
750
|
/**
|
|
504
751
|
* A list of gitignore-style patterns, every member checked.
|
|
505
752
|
*
|
|
@@ -694,6 +941,8 @@ function excludeLines(document, lineCounter) {
|
|
|
694
941
|
export function parseConfig(text) {
|
|
695
942
|
const issues = [];
|
|
696
943
|
const details = [];
|
|
944
|
+
/** Filled only by a fully valid `media:` table; validation failures throw below. */
|
|
945
|
+
let media;
|
|
697
946
|
// A leading BOM is what Notepad writes, and it is not YAML. Stripped rather
|
|
698
947
|
// than refused: the user did not type it and cannot see it.
|
|
699
948
|
//
|
|
@@ -832,6 +1081,9 @@ export function parseConfig(text) {
|
|
|
832
1081
|
checkOrigin(table['origin'], lines.get('origin'), issues, details);
|
|
833
1082
|
if ('language' in table)
|
|
834
1083
|
checkLanguage(table['language'], lines.get('language'), issues, details);
|
|
1084
|
+
if ('media' in table) {
|
|
1085
|
+
media = checkMedia(table['media'], lines.get('media'), mediaLines(document, lineCounter), mediaEatenTags(document), issues, details);
|
|
1086
|
+
}
|
|
835
1087
|
if ('exclude' in table) {
|
|
836
1088
|
checkExclude(table['exclude'], lines.get('exclude'), excludeLines(document, lineCounter), taggedExcludeMembers(document), issues, details);
|
|
837
1089
|
}
|
|
@@ -845,6 +1097,7 @@ export function parseConfig(text) {
|
|
|
845
1097
|
origin: 'origin' in table ? table['origin'] : DEFAULTS.origin,
|
|
846
1098
|
exclude: 'exclude' in table ? [...table['exclude']] : [],
|
|
847
1099
|
language: 'language' in table ? table['language'] : DEFAULTS.language,
|
|
1100
|
+
media,
|
|
848
1101
|
};
|
|
849
1102
|
}
|
|
850
1103
|
/**
|